From f193304b1108057e85ec075cfaf79def18d09044 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 25 Aug 2026 13:31:43 -0700 Subject: [PATCH 1/5] fix(knowledge): harden ingestion pipeline --- apps/docs/openapi-v2-knowledge.json | 8 + .../app/api/knowledge/search/utils.test.ts | 67 +- apps/sim/app/api/knowledge/utils.test.ts | 47 +- .../app/api/tools/embeddings/route.test.ts | 31 +- apps/sim/app/api/tools/embeddings/route.ts | 5 + .../app/api/v2/knowledge/connector-utils.ts | 1 + .../connectors-section.test.tsx | 25 + .../connectors-section/connectors-section.tsx | 16 +- .../knowledge-connector-sync.test.ts | 174 +- .../background/knowledge-connector-sync.ts | 48 +- .../background/knowledge-processing.test.ts | 390 +- apps/sim/background/knowledge-processing.ts | 96 +- .../connectors/fireflies/fireflies.test.ts | 390 +- apps/sim/connectors/fireflies/fireflies.ts | 406 +- apps/sim/connectors/fireflies/meta.ts | 6 + .../google-docs/google-docs.test.ts | 707 + .../sim/connectors/google-docs/google-docs.ts | 316 +- .../google-drive/google-drive-errors.ts | 142 + .../google-drive/google-drive.test.ts | 470 + .../connectors/google-drive/google-drive.ts | 406 +- apps/sim/connectors/notion/meta.ts | 2 +- apps/sim/connectors/notion/notion.test.ts | 1145 + apps/sim/connectors/notion/notion.ts | 1114 +- apps/sim/connectors/onedrive/onedrive.test.ts | 175 +- apps/sim/connectors/onedrive/onedrive.ts | 119 +- .../connectors/sharepoint/sharepoint.test.ts | 163 +- apps/sim/connectors/sharepoint/sharepoint.ts | 164 +- apps/sim/connectors/types.ts | 41 + apps/sim/connectors/utils.test.ts | 101 +- apps/sim/connectors/utils.ts | 248 +- .../lib/api/contracts/knowledge/connectors.ts | 1 + apps/sim/lib/api/contracts/v2/knowledge.ts | 6 + apps/sim/lib/atlassian/discovery.test.ts | 6 +- apps/sim/lib/chunkers/chunk-budget.ts | 31 + apps/sim/lib/chunkers/chunk-limit.test.ts | 114 + apps/sim/lib/chunkers/docs-chunker.test.ts | 40 + apps/sim/lib/chunkers/docs-chunker.ts | 16 +- apps/sim/lib/chunkers/index.ts | 1 + .../lib/chunkers/json-yaml-chunker.test.ts | 37 + apps/sim/lib/chunkers/json-yaml-chunker.ts | 169 +- apps/sim/lib/chunkers/recursive-chunker.ts | 45 +- apps/sim/lib/chunkers/regex-chunker.test.ts | 29 + apps/sim/lib/chunkers/regex-chunker.ts | 85 +- apps/sim/lib/chunkers/sentence-chunker.ts | 46 +- .../chunkers/structured-data-chunker.test.ts | 37 + .../lib/chunkers/structured-data-chunker.ts | 83 +- apps/sim/lib/chunkers/text-chunker.ts | 47 +- apps/sim/lib/chunkers/token-chunker.ts | 35 +- apps/sim/lib/chunkers/types.ts | 1 + apps/sim/lib/chunkers/utils.ts | 62 +- .../linear-regex.differential.test.ts | 17 + .../lib/core/security/linear-regex.test.ts | 11 + apps/sim/lib/core/security/linear-regex.ts | 61 +- apps/sim/lib/core/security/redaction.test.ts | 105 + apps/sim/lib/core/security/redaction.ts | 120 +- apps/sim/lib/core/utils/stream-limits.test.ts | 59 + apps/sim/lib/core/utils/stream-limits.ts | 21 +- apps/sim/lib/embeddings/client.test.ts | 581 +- apps/sim/lib/embeddings/client.ts | 419 +- apps/sim/lib/embeddings/index.ts | 10 +- apps/sim/lib/embeddings/quota-circuit.test.ts | 130 + apps/sim/lib/embeddings/quota-circuit.ts | 136 + apps/sim/lib/file-parsers/csv-parser.ts | 10 +- apps/sim/lib/file-parsers/data-uri.test.ts | 113 + apps/sim/lib/file-parsers/data-uri.ts | 176 + apps/sim/lib/file-parsers/doc-parser.test.ts | 3 +- apps/sim/lib/file-parsers/doc-parser.ts | 29 +- apps/sim/lib/file-parsers/docx-parser.ts | 56 +- apps/sim/lib/file-parsers/errors.test.ts | 59 + apps/sim/lib/file-parsers/errors.ts | 71 + apps/sim/lib/file-parsers/html-parser.test.ts | 6 + apps/sim/lib/file-parsers/html-parser.ts | 15 +- apps/sim/lib/file-parsers/index.ts | 11 +- apps/sim/lib/file-parsers/json-parser.ts | 51 +- .../lib/file-parsers/officeparser-module.ts | 12 +- apps/sim/lib/file-parsers/ooxml-limits.ts | 14 + .../lib/file-parsers/opendocument-parser.ts | 17 +- .../lib/file-parsers/parser-formats.test.ts | 10 + apps/sim/lib/file-parsers/pptx-parser.test.ts | 30 + apps/sim/lib/file-parsers/pptx-parser.ts | 92 +- apps/sim/lib/file-parsers/xlsx-parser.ts | 203 +- .../file-parsers/xlsx-preview-bound.test.ts | 86 +- apps/sim/lib/file-parsers/yaml-parser.test.ts | 4 + apps/sim/lib/file-parsers/yaml-parser.ts | 29 +- apps/sim/lib/file-parsers/zip-guard.test.ts | 44 +- apps/sim/lib/file-parsers/zip-guard.ts | 43 +- .../lib/knowledge/application/documents.ts | 5 +- .../knowledge/connectors/sync-engine.test.ts | 1091 +- .../lib/knowledge/connectors/sync-engine.ts | 1302 +- apps/sim/lib/knowledge/constants.ts | 12 +- .../documents/document-indexing-usage.test.ts | 1 + .../document-processing-error.test.ts | 222 + .../documents/document-processing-error.ts | 244 + .../document-processing-source.test.ts | 675 +- .../document-processor-chunk-limit.test.ts | 98 + .../knowledge/documents/document-processor.ts | 518 +- .../documents/pdf-ocr-triage.test.ts | 150 +- .../documents/processing-claim.test.ts | 74 +- .../knowledge/documents/processing-claim.ts | 37 +- .../documents/processing-dispatch.test.ts | 97 + .../documents/processing-dispatch.ts | 56 +- .../processing-outbox-handler.test.ts | 20 +- .../documents/processing-outbox-handler.ts | 5 +- .../knowledge/documents/processing-payload.ts | 54 + .../documents/processing-queue.test.ts | 752 +- .../processing-quota-continuation.ts | 57 + .../documents/processing-timeouts.server.ts | 14 + .../documents/retry-processing-grace.test.ts | 47 +- .../documents/secure-fetch.server.ts | 9 +- apps/sim/lib/knowledge/documents/service.ts | 775 +- .../documents/storage-billing.test.ts | 162 +- apps/sim/lib/knowledge/documents/types.ts | 37 +- .../documents/unreadable-document.test.ts | 15 + .../sim/lib/knowledge/documents/utils.test.ts | 328 +- apps/sim/lib/knowledge/documents/utils.ts | 155 +- apps/sim/lib/knowledge/embedding-models.ts | 3 + .../knowledge/orchestration/documents.test.ts | 4 + .../lib/knowledge/orchestration/documents.ts | 4 +- apps/sim/lib/oauth/oauth.test.ts | 105 +- apps/sim/lib/oauth/oauth.ts | 121 +- apps/sim/tools/mistral/parser.ts | 8 +- apps/sim/tools/netsuite/utils.ts | 1 + .../0306_knowledge_pipeline_hardening.sql | 6 + .../db/migrations/meta/0306_snapshot.json | 20139 ++++++++++++++++ packages/db/migrations/meta/_journal.json | 7 + packages/db/schema.ts | 5 + packages/sim-cli/src/generated/v2-api.ts | 1 + packages/testing/src/mocks/schema.mock.ts | 2 + 128 files changed, 35936 insertions(+), 2250 deletions(-) create mode 100644 apps/sim/connectors/google-docs/google-docs.test.ts create mode 100644 apps/sim/connectors/google-drive/google-drive-errors.ts create mode 100644 apps/sim/connectors/google-drive/google-drive.test.ts create mode 100644 apps/sim/connectors/notion/notion.test.ts create mode 100644 apps/sim/lib/chunkers/chunk-budget.ts create mode 100644 apps/sim/lib/chunkers/chunk-limit.test.ts create mode 100644 apps/sim/lib/embeddings/quota-circuit.test.ts create mode 100644 apps/sim/lib/embeddings/quota-circuit.ts create mode 100644 apps/sim/lib/file-parsers/data-uri.test.ts create mode 100644 apps/sim/lib/file-parsers/data-uri.ts create mode 100644 apps/sim/lib/file-parsers/errors.test.ts create mode 100644 apps/sim/lib/file-parsers/errors.ts create mode 100644 apps/sim/lib/file-parsers/pptx-parser.test.ts create mode 100644 apps/sim/lib/knowledge/documents/document-processing-error.test.ts create mode 100644 apps/sim/lib/knowledge/documents/document-processing-error.ts create mode 100644 apps/sim/lib/knowledge/documents/document-processor-chunk-limit.test.ts create mode 100644 apps/sim/lib/knowledge/documents/processing-dispatch.test.ts create mode 100644 apps/sim/lib/knowledge/documents/processing-quota-continuation.ts create mode 100644 apps/sim/lib/knowledge/documents/processing-timeouts.server.ts create mode 100644 packages/db/migrations/0306_knowledge_pipeline_hardening.sql create mode 100644 packages/db/migrations/meta/0306_snapshot.json diff --git a/apps/docs/openapi-v2-knowledge.json b/apps/docs/openapi-v2-knowledge.json index 03293b8cdf2..33da875662d 100644 --- a/apps/docs/openapi-v2-knowledge.json +++ b/apps/docs/openapi-v2-knowledge.json @@ -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" ], diff --git a/apps/sim/app/api/knowledge/search/utils.test.ts b/apps/sim/app/api/knowledge/search/utils.test.ts index c5ae91e4c30..a14e55dcf3b 100644 --- a/apps/sim/app/api/knowledge/search/utils.test.ts +++ b/apps/sim/app/api/knowledge/search/utils.test.ts @@ -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' }, diff --git a/apps/sim/app/api/knowledge/utils.test.ts b/apps/sim/app/api/knowledge/utils.test.ts index 421414d602f..c1a6b445904 100644 --- a/apps/sim/app/api/knowledge/utils.test.ts +++ b/apps/sim/app/api/knowledge/utils.test.ts @@ -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']) diff --git a/apps/sim/app/api/tools/embeddings/route.test.ts b/apps/sim/app/api/tools/embeddings/route.test.ts index 9affe4c1b62..b6586902869 100644 --- a/apps/sim/app/api/tools/embeddings/route.test.ts +++ b/apps/sim/app/api/tools/embeddings/route.test.ts @@ -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()) diff --git a/apps/sim/app/api/tools/embeddings/route.ts b/apps/sim/app/api/tools/embeddings/route.ts index aa61800689d..23a306b0157 100644 --- a/apps/sim/app/api/tools/embeddings/route.ts +++ b/apps/sim/app/api/tools/embeddings/route.ts @@ -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 }) } diff --git a/apps/sim/app/api/v2/knowledge/connector-utils.ts b/apps/sim/app/api/v2/knowledge/connector-utils.ts index b8330c1bdc7..a3e50becdf1 100644 --- a/apps/sim/app/api/v2/knowledge/connector-utils.ts +++ b/apps/sim/app/api/v2/knowledge/connector-utils.ts @@ -44,6 +44,7 @@ interface KnowledgeConnectorSyncLogProjection { docsUpdated: number docsDeleted: number docsUnchanged: number + docsSkipped: number docsFailed: number errorMessage: string | null } diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connectors-section.test.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connectors-section.test.tsx index f481648f220..bbca18b7547 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connectors-section.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connectors-section.test.tsx @@ -136,6 +136,7 @@ function makeLog(overrides: Partial & Pick): 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' })) diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connectors-section.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connectors-section.tsx index f72341b5c83..e5efda502a3 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connectors-section.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connectors-section.tsx @@ -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 (
@@ -763,6 +767,16 @@ export function SyncHistory({ logs, isLoading }: SyncHistoryProps) { !{log.docsFailed} )} + {log.docsSkipped > 0 && ( + <> + {(log.docsAdded > 0 || + log.docsUpdated > 0 || + log.docsDeleted > 0 || + log.docsFailed > 0) && + ' '} + ⊘{log.docsSkipped} + + )} ) : ( 'No changes' diff --git a/apps/sim/background/knowledge-connector-sync.test.ts b/apps/sim/background/knowledge-connector-sync.test.ts index b817ea2d636..a1023c80091 100644 --- a/apps/sim/background/knowledge-connector-sync.test.ts +++ b/apps/sim/background/knowledge-connector-sync.test.ts @@ -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', + }) + }) }) diff --git a/apps/sim/background/knowledge-connector-sync.ts b/apps/sim/background/knowledge-connector-sync.ts index c4805bef9fd..f7ba8e80e4d 100644 --- a/apps/sim/background/knowledge-connector-sync.ts +++ b/apps/sim/background/knowledge-connector-sync.ts @@ -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 +): 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, } diff --git a/apps/sim/background/knowledge-processing.test.ts b/apps/sim/background/knowledge-processing.test.ts index a7dd472a22a..741beb58390 100644 --- a/apps/sim/background/knowledge-processing.test.ts +++ b/apps/sim/background/knowledge-processing.test.ts @@ -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 + } + 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) + }) }) diff --git a/apps/sim/background/knowledge-processing.ts b/apps/sim/background/knowledge-processing.ts index 8af82a2f371..43256bed6d6 100644 --- a/apps/sim/background/knowledge-processing.ts +++ b/apps/sim/background/knowledge-processing.ts @@ -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), }) diff --git a/apps/sim/connectors/fireflies/fireflies.test.ts b/apps/sim/connectors/fireflies/fireflies.test.ts index 4693726a2b3..9ba06c72c30 100644 --- a/apps/sim/connectors/fireflies/fireflies.test.ts +++ b/apps/sim/connectors/fireflies/fireflies.test.ts @@ -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 } /** 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 }[] +) { 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 = {} 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 = {} @@ -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 () => 'gateway', - } as unknown as Response) + vi.useFakeTimers() + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response('gateway')) + ) const syncContext: Record = {} - 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( + 'Fireflies API returned malformed transcript-list data' + ) + }) + + it('rejects malformed transcript rows instead of silently filtering them', async () => { + mockGraphQL([{ body: { data: { transcripts: [{}] } } }]) + await expect(firefliesConnector.listDocuments('key', {}, undefined, {})).rejects.toThrow( - /malformed/i + 'Fireflies API returned malformed transcript metadata' ) }) - it('surfaces the errors[] message on a non-2xx response', async () => { + 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') } } }]) diff --git a/apps/sim/connectors/fireflies/fireflies.ts b/apps/sim/connectors/fireflies/fireflies.ts index 5f56f0b368d..cf87305c563 100644 --- a/apps/sim/connectors/fireflies/fireflies.ts +++ b/apps/sim/connectors/fireflies/fireflies.ts @@ -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 = {}, - retryOptions?: Parameters[2] + retryOptions?: RetryOptions ): Promise> { - const response = await fetchWithRetry( - FIREFLIES_GRAPHQL_URL, - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${accessToken}`, - }, - body: JSON.stringify({ query, variables }), - }, - retryOptions - ) + 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. - */ - const data = (await response.json().catch(() => null)) as { - data?: Record | 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 - ) - } + /** + * 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 + } - if (!response.ok) { - throw new Error(`Fireflies API HTTP error: ${response.status}`) - } + let parsedBody: unknown = null + try { + parsedBody = JSON.parse(rawBody) + } catch { + parsedBody = null + } - /** - * 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') - } + const data = parsedBody as { + data?: Record | 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 + return data.data + }, + { + 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, + } + ) } -/** - * 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 { 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 ): Promise => { 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 ): 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 { diff --git a/apps/sim/connectors/fireflies/meta.ts b/apps/sim/connectors/fireflies/meta.ts index 26c1248b033..4e0193961d3 100644 --- a/apps/sim/connectors/fireflies/meta.ts +++ b/apps/sim/connectors/fireflies/meta.ts @@ -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', diff --git a/apps/sim/connectors/google-docs/google-docs.test.ts b/apps/sim/connectors/google-docs/google-docs.test.ts new file mode 100644 index 00000000000..e2e86fec321 --- /dev/null +++ b/apps/sim/connectors/google-docs/google-docs.test.ts @@ -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({ + 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 = { 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 = {} + + 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 = {} + + 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 = {} + + 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() + } + ) + }) +}) diff --git a/apps/sim/connectors/google-docs/google-docs.ts b/apps/sim/connectors/google-docs/google-docs.ts index 51114f376e5..150f807074e 100644 --- a/apps/sim/connectors/google-docs/google-docs.ts +++ b/apps/sim/connectors/google-docs/google-docs.ts @@ -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 + 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 + footers?: Record + footnotes?: Record + inlineObjects?: Record + positionedObjects?: Record } 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 { + 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[] { +interface DocsExtraction { + parts: string[] + hasUnresolvedContent: boolean +} + +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 false +} + +function appendRegionText(parts: string[], regions?: Record): 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 content = tab.documentTab?.body?.content - if (content) parts.push(...extractTextFromStructuralElements(content)) - if (tab.childTabs?.length) parts.push(...extractTextFromTabs(tab.childTabs)) + 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 + 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 { +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 => { 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 ): 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)}`, + } } } diff --git a/apps/sim/connectors/google-drive/google-drive-errors.ts b/apps/sim/connectors/google-drive/google-drive-errors.ts new file mode 100644 index 00000000000..fb2ffeb81e1 --- /dev/null +++ b/apps/sim/connectors/google-drive/google-drive-errors.ts @@ -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 { + 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 { + 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) + ) +} diff --git a/apps/sim/connectors/google-drive/google-drive.test.ts b/apps/sim/connectors/google-drive/google-drive.test.ts new file mode 100644 index 00000000000..fde940e924f --- /dev/null +++ b/apps/sim/connectors/google-drive/google-drive.test.ts @@ -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 = {}): Record { + 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 = {} + 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 = {} + + 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() + } + ) +}) diff --git a/apps/sim/connectors/google-drive/google-drive.ts b/apps/sim/connectors/google-drive/google-drive.ts index 513aafebeec..fee472e9f7f 100644 --- a/apps/sim/connectors/google-drive/google-drive.ts +++ b/apps/sim/connectors/google-drive/google-drive.ts @@ -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 = { +const XLSX_MIME_TYPE = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' + +const GOOGLE_WORKSPACE_EXPORTS: Record = { '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 { + 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 { + 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 { - const exportMimeType = GOOGLE_WORKSPACE_MIME_TYPES[sourceMimeType] +): Promise { + 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 { @@ -88,15 +144,11 @@ async function downloadTextFile(accessToken: string, fileId: string): Promise { - if (isGoogleWorkspaceFile(mimeType)) { - return exportGoogleWorkspaceFile(accessToken, fileId, mimeType) +type FilePayload = Pick + +function xlsxFileName(name: string): string { + return name.toLowerCase().endsWith('.xlsx') ? name : `${name}.xlsx` +} + +async function fetchFilePayload(accessToken: string, file: DriveFile): Promise { + 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 { const parts: string[] = ['trashed = false'] @@ -161,10 +295,7 @@ function buildQuery(sourceConfig: Record): 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 { } 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 + const page = takeIndexableWithinCap( + pageDocuments, + isSkippedDocument, + maxFiles, + previouslyFetched + ) + + const totalFetched = previouslyFetched + page.indexableCount if (syncContext) syncContext.totalDocsFetched = totalFetched - const hitLimit = maxFiles > 0 && totalFetched >= maxFiles + const hitLimit = page.capReached - const nextPageToken = data.nextPageToken as string | undefined + 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 ): 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 } } diff --git a/apps/sim/connectors/notion/meta.ts b/apps/sim/connectors/notion/meta.ts index e1220cdb94d..ad4a8b13cab 100644 --- a/apps/sim/connectors/notion/meta.ts +++ b/apps/sim/connectors/notion/meta.ts @@ -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' }, ], }, { diff --git a/apps/sim/connectors/notion/notion.test.ts b/apps/sim/connectors/notion/notion.test.ts new file mode 100644 index 00000000000..a128aa1a383 --- /dev/null +++ b/apps/sim/connectors/notion/notion.test.ts @@ -0,0 +1,1145 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockFetchWithRetry, mockReadBoundedHttpErrorPayload } = vi.hoisted(() => ({ + mockFetchWithRetry: vi.fn(), + mockReadBoundedHttpErrorPayload: vi.fn(), +})) + +vi.mock('@/lib/knowledge/documents/utils', () => ({ + fetchWithRetry: mockFetchWithRetry, + readBoundedHttpErrorPayload: mockReadBoundedHttpErrorPayload, + VALIDATE_RETRY_OPTIONS: {}, +})) +vi.mock('@/components/icons', () => ({ NotionIcon: () => null })) + +import { notionConnector } from '@/connectors/notion/notion' +import { CONNECTOR_MAX_FILE_BYTES } from '@/connectors/utils' + +function notionResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }) +} + +function page(id = 'page-1') { + return { + object: 'page', + id, + in_trash: false, + url: `https://www.notion.so/${id}`, + created_time: '2026-08-01T00:00:00.000Z', + last_edited_time: '2026-08-02T00:00:00.000Z', + parent: { type: 'workspace', workspace: true }, + properties: { + Name: { + type: 'title', + title: [{ plain_text: 'Test page' }], + }, + }, + } +} + +function dataSources(prefix: string, count: number): { id: string; name: string }[] { + return Array.from({ length: count }, (_, index) => ({ + id: `${prefix}-${index + 1}`, + name: `${prefix} ${index + 1}`, + })) +} + +function dataSourceCursor(value: Record): string { + return `notion-data-sources:v1:${encodeURIComponent(JSON.stringify(value))}` +} + +beforeEach(() => { + mockReadBoundedHttpErrorPayload.mockReset() + mockReadBoundedHttpErrorPayload.mockImplementation(async (response: Response) => ({ + ok: true, + body: await response.text(), + })) +}) + +describe('notion markdown hydration', () => { + beforeEach(() => { + mockFetchWithRetry.mockReset() + }) + + it('uses the current API version and retrieves complete page markdown in one request', async () => { + mockFetchWithRetry.mockResolvedValueOnce(notionResponse(page())).mockResolvedValueOnce( + notionResponse({ + markdown: '# Overview\n\nNested tab content', + truncated: false, + unknown_block_ids: [], + }) + ) + + const document = await notionConnector.getDocument('token', {}, 'page-1') + + expect(document?.content).toContain('Overview') + expect(document?.content).toContain('Nested tab content') + expect(document?.contentHash).toBe('notion:v3:page-1:2026-08-02T00:00:00.000Z') + expect(mockFetchWithRetry).toHaveBeenCalledTimes(2) + expect(mockFetchWithRetry.mock.calls.map(([url]) => String(url))).toEqual([ + 'https://api.notion.com/v1/pages/page-1', + 'https://api.notion.com/v1/pages/page-1/markdown?include_transcript=true', + ]) + + for (const [, options] of mockFetchWithRetry.mock.calls) { + expect(((options as RequestInit).headers as Record)['Notion-Version']).toBe( + '2026-03-11' + ) + } + }) + + it('rejects oversized successful page metadata before parsing JSON', async () => { + mockFetchWithRetry.mockResolvedValueOnce( + new Response(`{"padding":"${'x'.repeat(1024 * 1024)}"}`, { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + ) + + await expect(notionConnector.getDocument('token', {}, 'page-1')).rejects.toThrow( + 'Notion page page-1 metadata exceeds the 1048576 byte limit' + ) + expect(mockFetchWithRetry).toHaveBeenCalledTimes(1) + }) + + it('recovers truncated markdown from every provider-supplied block ID', async () => { + mockFetchWithRetry + .mockResolvedValueOnce(notionResponse(page())) + .mockResolvedValueOnce( + notionResponse({ + markdown: '# Root\n\n', + truncated: true, + unknown_block_ids: ['nested-1'], + }) + ) + .mockResolvedValueOnce( + notionResponse({ + markdown: 'Recovered nested content', + truncated: false, + unknown_block_ids: [], + }) + ) + + const document = await notionConnector.getDocument('token', {}, 'page-1') + + expect(document?.content).toContain('# Root') + expect(document?.content).toContain('Recovered nested content') + expect(mockFetchWithRetry.mock.calls.map(([url]) => String(url))).toEqual([ + 'https://api.notion.com/v1/pages/page-1', + 'https://api.notion.com/v1/pages/page-1/markdown?include_transcript=true', + 'https://api.notion.com/v1/pages/nested-1/markdown?include_transcript=true', + ]) + }) + + it('marks inaccessible recovery blocks retryable instead of stabilizing partial markdown', async () => { + mockFetchWithRetry + .mockResolvedValueOnce(notionResponse(page())) + .mockResolvedValueOnce( + notionResponse({ + markdown: 'Available content', + truncated: true, + unknown_block_ids: ['inaccessible-1'], + }) + ) + .mockResolvedValueOnce( + notionResponse( + { + object: 'error', + code: 'object_not_found', + message: 'Block is inaccessible', + request_id: 'request-inaccessible-1', + }, + 404 + ) + ) + + const document = await notionConnector.getDocument('token', {}, 'page-1') + + expect(document).toMatchObject({ + content: '', + contentDeferred: false, + contentHash: 'notion:v3:page-1:2026-08-02T00:00:00.000Z', + skippedRetryContentHash: 'notion:retry:v1:page-1', + skippedReason: + 'Notion page contains blocks the connection cannot access and was not indexed completely', + }) + expect(document?.skippedExistingDisposition).toBeUndefined() + }) + + it('marks an inaccessible unsupported-block fallback retryable', async () => { + mockFetchWithRetry + .mockResolvedValueOnce(notionResponse(page())) + .mockResolvedValueOnce( + notionResponse({ + markdown: 'Before unsupported block: ', + truncated: false, + unknown_block_ids: ['bookmark-1'], + }) + ) + .mockResolvedValueOnce( + notionResponse( + { + object: 'error', + code: 'object_not_found', + message: 'Block is inaccessible', + request_id: 'request-bookmark-1', + }, + 404 + ) + ) + + const document = await notionConnector.getDocument('token', {}, 'page-1') + + expect(document).toMatchObject({ + content: '', + contentHash: 'notion:v3:page-1:2026-08-02T00:00:00.000Z', + skippedRetryContentHash: 'notion:retry:v1:page-1', + skippedReason: + 'Notion page contains blocks the connection cannot access and was not indexed completely', + }) + }) + + it('uses the block endpoint to recover unsupported markdown block types', async () => { + mockFetchWithRetry + .mockResolvedValueOnce(notionResponse(page())) + .mockResolvedValueOnce( + notionResponse({ + markdown: 'Before unsupported block: ', + truncated: false, + unknown_block_ids: ['bookmark-1'], + }) + ) + .mockResolvedValueOnce( + notionResponse({ + object: 'block', + id: 'bookmark-1', + type: 'bookmark', + bookmark: { + caption: [{ plain_text: 'Provider documentation' }], + url: 'https://developers.notion.com/', + }, + }) + ) + + const document = await notionConnector.getDocument('token', {}, 'page-1') + + expect(document?.content).toContain('Provider documentation') + expect(document?.content).toContain('https://developers.notion.com/') + expect(String(mockFetchWithRetry.mock.calls[2][0])).toBe( + 'https://api.notion.com/v1/blocks/bookmark-1' + ) + }) + + it('recovers the expression from an unsupported equation block', async () => { + mockFetchWithRetry + .mockResolvedValueOnce(notionResponse(page())) + .mockResolvedValueOnce( + notionResponse({ + markdown: 'Before unsupported equation: ', + truncated: false, + unknown_block_ids: ['equation-1'], + }) + ) + .mockResolvedValueOnce( + notionResponse({ + object: 'block', + id: 'equation-1', + type: 'equation', + equation: { expression: 'e=mc^2' }, + }) + ) + + const document = await notionConnector.getDocument('token', {}, 'page-1') + + expect(document).toMatchObject({ contentDeferred: false }) + expect(document?.content).toContain('e=mc^2') + expect(document?.skippedReason).toBeUndefined() + expect(String(mockFetchWithRetry.mock.calls[2][0])).toBe( + 'https://api.notion.com/v1/blocks/equation-1' + ) + }) + + it('marks an unrecoverable truncated response as skipped rather than storing partial content', async () => { + mockFetchWithRetry + .mockResolvedValueOnce(notionResponse(page())) + .mockResolvedValueOnce( + notionResponse({ markdown: 'Partial', truncated: true, unknown_block_ids: [] }) + ) + + const document = await notionConnector.getDocument('token', {}, 'page-1') + + expect(document?.contentDeferred).toBe(false) + expect(document?.skippedReason).toContain('truncated markdown without recovery block IDs') + }) + + it('bounds aggregate markdown recovery requests across nested unknown blocks', async () => { + const firstHundredIds = Array.from({ length: 100 }, (_, index) => `nested-${index + 1}`) + mockFetchWithRetry + .mockResolvedValueOnce(notionResponse(page())) + .mockResolvedValueOnce( + notionResponse({ markdown: 'Root', truncated: true, unknown_block_ids: firstHundredIds }) + ) + .mockImplementation((url: string) => { + if (url.includes('/pages/nested-1/markdown')) { + return Promise.resolve( + notionResponse({ + markdown: 'Nested 1', + truncated: true, + unknown_block_ids: ['nested-101'], + }) + ) + } + if (url.includes('/pages/')) { + return Promise.resolve( + notionResponse( + { + object: 'error', + code: 'validation_error', + message: 'Unsupported markdown block type', + request_id: 'request-unsupported', + }, + 400 + ) + ) + } + return Promise.resolve( + notionResponse({ object: 'block', type: 'bookmark', bookmark: { url } }) + ) + }) + + const document = await notionConnector.getDocument('token', {}, 'page-1') + + expect(document?.contentDeferred).toBe(false) + expect(document?.skippedReason).toContain('more than 200 markdown recovery requests') + expect(mockFetchWithRetry).toHaveBeenCalledTimes(202) + }) + + it('bounds aggregate unique recovery IDs before the pending queue can fan out', async () => { + const ids = (start: number, count: number) => + Array.from({ length: count }, (_, index) => `nested-${start + index}`) + mockFetchWithRetry + .mockResolvedValueOnce(notionResponse(page())) + .mockResolvedValueOnce( + notionResponse({ markdown: 'Root', truncated: true, unknown_block_ids: ids(1, 100) }) + ) + .mockResolvedValueOnce( + notionResponse({ markdown: 'Nested 1', truncated: true, unknown_block_ids: ids(101, 100) }) + ) + .mockResolvedValueOnce( + notionResponse({ markdown: 'Nested 2', truncated: true, unknown_block_ids: ['nested-201'] }) + ) + + const document = await notionConnector.getDocument('token', {}, 'page-1') + + expect(document?.contentDeferred).toBe(false) + expect(document?.skippedReason).toContain('more than 200 unique markdown recovery IDs') + expect(mockFetchWithRetry).toHaveBeenCalledTimes(4) + }) + + it('omits free-form provider diagnostics from the markdown endpoint', async () => { + mockFetchWithRetry.mockResolvedValueOnce(notionResponse(page())).mockResolvedValueOnce( + notionResponse( + { + object: 'error', + code: 'validation_error', + message: 'The start_cursor provided is invalid. Authorization: Bearer production-secret', + request_id: 'request-2', + }, + 400 + ) + ) + + await expect(notionConnector.getDocument('token', {}, 'page-1')).rejects.toThrow( + 'Failed to fetch markdown for page-1: 400, code=validation_error, requestId=request-2' + ) + }) + + it('preserves only machine identifiers from a valid error envelope larger than 2KB', async () => { + mockFetchWithRetry.mockResolvedValueOnce(notionResponse(page())).mockResolvedValueOnce( + notionResponse( + { + object: 'error', + padding: 'x'.repeat(3000), + code: 'validation_error', + message: 'The start_cursor provided is invalid', + request_id: 'request-large', + }, + 400 + ) + ) + + await expect(notionConnector.getDocument('token', {}, 'page-1')).rejects.toThrow( + 'Failed to fetch markdown for page-1: 400, code=validation_error, requestId=request-large' + ) + }) + + it('degrades to status-only diagnostics when the bounded error payload is unavailable', async () => { + mockReadBoundedHttpErrorPayload.mockResolvedValueOnce({ ok: false, reason: 'too_large' }) + mockFetchWithRetry.mockResolvedValueOnce(notionResponse(page())).mockResolvedValueOnce( + notionResponse( + { + object: 'error', + code: 'validation_error', + message: 'This diagnostic must not be consumed', + request_id: 'request-omitted', + }, + 400 + ) + ) + + await expect(notionConnector.getDocument('token', {}, 'page-1')).rejects.toMatchObject({ + message: 'Failed to fetch markdown for page-1: 400', + status: 400, + code: undefined, + requestId: undefined, + }) + expect(mockReadBoundedHttpErrorPayload).toHaveBeenCalledTimes(1) + }) + + it('records an oversized markdown response as an intrinsic skipped document', async () => { + mockFetchWithRetry.mockResolvedValueOnce(notionResponse(page())).mockResolvedValueOnce( + new Response('{}', { + status: 200, + headers: { 'Content-Length': String(CONNECTOR_MAX_FILE_BYTES + 1) }, + }) + ) + + const document = await notionConnector.getDocument('token', {}, 'page-1') + + expect(document?.contentDeferred).toBe(false) + expect(document?.skippedReason).toContain('100MB size limit') + }) + + it('propagates an ambiguous metadata 404 so hydration is retried', async () => { + mockFetchWithRetry.mockResolvedValueOnce( + notionResponse( + { + object: 'error', + code: 'object_not_found', + message: 'Page not found or integration access was removed', + request_id: 'request-page-1', + }, + 404 + ) + ) + + await expect(notionConnector.getDocument('token', {}, 'page-1')).rejects.toThrow( + 'Failed to get Notion page: 404, code=object_not_found' + ) + expect(mockFetchWithRetry).toHaveBeenCalledTimes(1) + }) +}) + +describe('notion listing completeness', () => { + beforeEach(() => { + mockFetchWithRetry.mockReset() + }) + + it('rejects a malformed workspace search result list', async () => { + mockFetchWithRetry.mockResolvedValueOnce(notionResponse({ has_more: false })) + + await expect(notionConnector.listDocuments('token', {}, undefined, {})).rejects.toThrow( + 'Notion workspace search returned a malformed results list' + ) + }) + + it('rejects a malformed configured data-source result list', async () => { + mockFetchWithRetry + .mockResolvedValueOnce( + notionResponse({ + object: 'database', + id: 'database-1', + data_sources: [{ id: 'source-1', name: 'Primary' }], + }) + ) + .mockResolvedValueOnce(notionResponse({ has_more: false })) + + await expect( + notionConnector.listDocuments( + 'token', + { scope: 'database', databaseId: 'database-1' }, + undefined, + {} + ) + ).rejects.toThrow('Notion data source source-1 query returned a malformed results list') + }) + + it('does not mark an exactly exhausted workspace listing as capped', async () => { + mockFetchWithRetry.mockResolvedValueOnce( + notionResponse({ + results: [page('page-1'), page('page-2')], + has_more: false, + next_cursor: null, + }) + ) + const syncContext: Record = {} + + const result = await notionConnector.listDocuments( + 'token', + { maxPages: '2' }, + undefined, + syncContext + ) + + expect(result.documents).toHaveLength(2) + expect(result.hasMore).toBe(false) + expect(result.reconciliationSafe).toBe(false) + expect(syncContext.listingCapped).toBeUndefined() + }) + + it('marks the listing capped when the same limit hides another workspace page', async () => { + mockFetchWithRetry.mockResolvedValueOnce( + notionResponse({ + results: [page('page-1'), page('page-2')], + has_more: true, + next_cursor: 'cursor-2', + }) + ) + const syncContext: Record = {} + + const result = await notionConnector.listDocuments( + 'token', + { maxPages: '2' }, + undefined, + syncContext + ) + + expect(result.hasMore).toBe(false) + expect(syncContext.listingCapped).toBe(true) + }) + + it('discovers and queries every current data source for a configured database ID', async () => { + mockFetchWithRetry + .mockResolvedValueOnce( + notionResponse({ + object: 'database', + id: 'database-1', + data_sources: [ + { id: 'source-1', name: 'Primary' }, + { id: 'source-2', name: 'Archive' }, + ], + }) + ) + .mockResolvedValueOnce( + notionResponse({ results: [page('page-1')], has_more: false, next_cursor: null }) + ) + + const syncContext: Record = {} + const first = await notionConnector.listDocuments( + 'token', + { scope: 'database', databaseId: 'database-1' }, + undefined, + syncContext + ) + + expect(first.documents.map((document) => document.externalId)).toEqual(['page-1']) + expect(first.nextCursor).toBe(dataSourceCursor({ sourceIndex: 1 })) + expect(first.hasMore).toBe(true) + expect(String(mockFetchWithRetry.mock.calls[1][0])).toBe( + 'https://api.notion.com/v1/data_sources/source-1/query' + ) + + mockFetchWithRetry.mockResolvedValueOnce( + notionResponse({ results: [page('page-2')], has_more: false, next_cursor: null }) + ) + + const second = await notionConnector.listDocuments( + 'token', + { scope: 'database', databaseId: 'database-1' }, + first.nextCursor, + syncContext + ) + + expect(second.documents.map((document) => document.externalId)).toEqual(['page-2']) + expect(second.hasMore).toBe(false) + expect(String(mockFetchWithRetry.mock.calls[2][0])).toBe( + 'https://api.notion.com/v1/data_sources/source-2/query' + ) + expect(mockFetchWithRetry).toHaveBeenCalledTimes(3) + }) + + it.each([undefined, null, '', ' '])( + 'stops safely when a data source has more rows without a usable cursor: %j', + async (nextCursor) => { + mockFetchWithRetry + .mockResolvedValueOnce( + notionResponse({ + object: 'database', + id: 'database-1', + data_sources: [ + { id: 'source-1', name: 'Primary' }, + { id: 'source-2', name: 'Archive' }, + ], + }) + ) + .mockResolvedValueOnce( + notionResponse({ + results: [page('page-1')], + has_more: true, + next_cursor: nextCursor, + }) + ) + + const syncContext: Record = {} + const result = await notionConnector.listDocuments( + 'token', + { scope: 'database', databaseId: 'database-1' }, + undefined, + syncContext + ) + + expect(result.documents.map((document) => document.externalId)).toEqual(['page-1']) + expect(result.hasMore).toBe(false) + expect(result.nextCursor).toBeUndefined() + expect(result.reconciliationSafe).toBe(false) + expect(syncContext.listingCapped).toBe(true) + expect(syncContext.reconciliationUnsafe).toBe(true) + expect(mockFetchWithRetry).toHaveBeenCalledTimes(2) + expect( + mockFetchWithRetry.mock.calls.some(([url]) => String(url).includes('/source-2/query')) + ).toBe(false) + } + ) + + it('makes a data-source listing non-authoritative when Notion reports its 10,000-row ceiling', async () => { + mockFetchWithRetry + .mockResolvedValueOnce( + notionResponse({ + object: 'database', + id: 'database-1', + data_sources: [{ id: 'source-1', name: 'Primary' }], + }) + ) + .mockResolvedValueOnce( + notionResponse({ + results: [page('page-10000')], + has_more: false, + next_cursor: null, + request_status: { + type: 'incomplete', + incomplete_reason: 'query_result_limit_reached', + }, + }) + ) + const syncContext: Record = {} + + const result = await notionConnector.listDocuments( + 'token', + { scope: 'database', databaseId: 'database-1' }, + undefined, + syncContext + ) + + expect(result.documents.map((document) => document.externalId)).toEqual(['page-10000']) + expect(result.hasMore).toBe(false) + expect(result.reconciliationSafe).toBe(false) + expect(syncContext.listingCapped).toBe(true) + expect(syncContext.reconciliationUnsafe).toBe(true) + }) + + it('bounds configured database IDs before validation fans out', async () => { + const databaseIds = Array.from({ length: 101 }, (_, index) => `database-${index + 1}`) + + await expect( + notionConnector.validateConfig('token', { + scope: 'database', + databaseId: databaseIds, + }) + ).resolves.toEqual({ + valid: false, + error: 'Notion connector supports at most 100 databases', + }) + expect(mockFetchWithRetry).not.toHaveBeenCalled() + }) + + it('bounds each successful database metadata response before JSON parsing', async () => { + mockFetchWithRetry.mockResolvedValueOnce( + new Response('{}', { + status: 200, + headers: { 'Content-Length': String(1024 * 1024 + 1) }, + }) + ) + const syncContext: Record = {} + + await expect( + notionConnector.listDocuments( + 'token', + { scope: 'database', databaseId: 'database-1' }, + undefined, + syncContext + ) + ).rejects.toThrow('metadata exceeds the 1048576 byte limit') + expect(syncContext.notionResolvedDataSources).toBeUndefined() + expect(mockFetchWithRetry).toHaveBeenCalledTimes(1) + }) + + it('rejects a database with too many data sources before caching them', async () => { + mockFetchWithRetry.mockResolvedValueOnce( + notionResponse({ data_sources: dataSources('source', 101) }) + ) + const syncContext: Record = {} + + await expect( + notionConnector.listDocuments( + 'token', + { scope: 'database', databaseId: 'database-1' }, + undefined, + syncContext + ) + ).rejects.toThrow('exposes more than 100 data sources') + expect(syncContext.notionResolvedDataSources).toBeUndefined() + expect(mockFetchWithRetry).toHaveBeenCalledTimes(1) + }) + + it('bounds total resolved data sources without storing a partial cache', async () => { + const databaseIds = Array.from({ length: 6 }, (_, index) => `database-${index + 1}`) + for (const databaseId of databaseIds) { + mockFetchWithRetry.mockResolvedValueOnce( + notionResponse({ data_sources: dataSources(`${databaseId}-source`, 100) }) + ) + } + const syncContext: Record = {} + + await expect( + notionConnector.listDocuments( + 'token', + { scope: 'database', databaseId: databaseIds }, + undefined, + syncContext + ) + ).rejects.toThrow('supports at most 500 data sources') + expect(syncContext.notionResolvedDataSources).toBeUndefined() + expect(mockFetchWithRetry).toHaveBeenCalledTimes(6) + }) + + it('does not trust an overbound retained data-source cache', async () => { + const syncContext: Record = { + notionResolvedDataSources: { + databaseIds: ['database-1'], + dataSources: Array.from({ length: 501 }, (_, index) => ({ + databaseId: 'database-1', + dataSourceId: `cached-source-${index + 1}`, + })), + }, + } + mockFetchWithRetry + .mockResolvedValueOnce(notionResponse({ data_sources: [{ id: 'source-1' }] })) + .mockResolvedValueOnce(notionResponse({ results: [], has_more: false, next_cursor: null })) + + await notionConnector.listDocuments( + 'token', + { scope: 'database', databaseId: 'database-1' }, + undefined, + syncContext + ) + + expect(String(mockFetchWithRetry.mock.calls[0][0])).toBe( + 'https://api.notion.com/v1/databases/database-1' + ) + expect(syncContext.notionResolvedDataSources).toEqual({ + databaseIds: ['database-1'], + dataSources: [{ databaseId: 'database-1', dataSourceId: 'source-1' }], + }) + }) + + it('keeps a bare provider cursor compatible for a single resolved data source', async () => { + mockFetchWithRetry + .mockResolvedValueOnce( + notionResponse({ data_sources: [{ id: 'source-1', name: 'Primary' }] }) + ) + .mockResolvedValueOnce( + notionResponse({ results: [page('page-1')], has_more: false, next_cursor: null }) + ) + + await notionConnector.listDocuments( + 'token', + { scope: 'database', databaseId: 'database-1' }, + 'legacy-provider-cursor' + ) + + const queryBody = JSON.parse( + String((mockFetchWithRetry.mock.calls[1][1] as RequestInit).body) + ) as Record + expect(queryBody.start_cursor).toBe('legacy-provider-cursor') + }) + + it('wraps a new provider cursor even when only one data source is configured', async () => { + mockFetchWithRetry + .mockResolvedValueOnce( + notionResponse({ data_sources: [{ id: 'source-1', name: 'Primary' }] }) + ) + .mockResolvedValueOnce( + notionResponse({ results: [page('page-1')], has_more: true, next_cursor: 'provider-next' }) + ) + + const result = await notionConnector.listDocuments('token', { + scope: 'database', + databaseId: 'database-1', + }) + + expect(result.nextCursor).toBe(dataSourceCursor({ sourceIndex: 0, cursor: 'provider-next' })) + }) + + it('passes a JSON-looking provider cursor through without interpreting it', async () => { + const providerCursor = JSON.stringify({ databaseIndex: 0, cursor: 'provider-opaque' }) + mockFetchWithRetry + .mockResolvedValueOnce( + notionResponse({ data_sources: [{ id: 'source-1', name: 'Primary' }] }) + ) + .mockResolvedValueOnce( + notionResponse({ results: [page('page-1')], has_more: false, next_cursor: null }) + ) + + await notionConnector.listDocuments( + 'token', + { scope: 'database', databaseId: 'database-1' }, + providerCursor + ) + + const queryBody = JSON.parse( + String((mockFetchWithRetry.mock.calls[1][1] as RequestInit).body) + ) as Record + expect(queryBody.start_cursor).toBe(providerCursor) + }) + + it('resumes a production legacy database cursor at the first current data source for that database', async () => { + mockFetchWithRetry + .mockResolvedValueOnce( + notionResponse({ + data_sources: [{ id: 'database-1-source-1' }, { id: 'database-1-source-2' }], + }) + ) + .mockResolvedValueOnce(notionResponse({ data_sources: [{ id: 'database-2-source-1' }] })) + .mockResolvedValueOnce( + notionResponse({ results: [page('page-2')], has_more: false, next_cursor: null }) + ) + + const result = await notionConnector.listDocuments( + 'token', + { scope: 'database', databaseId: ['database-1', 'database-2'] }, + JSON.stringify({ databaseIndex: 1, cursor: 'legacy-provider-cursor' }) + ) + + expect(result.documents.map((document) => document.externalId)).toEqual(['page-2']) + expect(String(mockFetchWithRetry.mock.calls[2][0])).toBe( + 'https://api.notion.com/v1/data_sources/database-2-source-1/query' + ) + const queryBody = JSON.parse( + String((mockFetchWithRetry.mock.calls[2][1] as RequestInit).body) + ) as Record + expect(queryBody.start_cursor).toBe('legacy-provider-cursor') + }) + + it.each([ + '{"databaseIndex":1', + JSON.stringify({ databaseIndex: '1', cursor: 'provider-cursor' }), + JSON.stringify({ databaseIndex: 1, cursor: 'provider-cursor', provider: true }), + ])('keeps malformed or lookalike JSON provider cursor opaque: %s', async (providerCursor) => { + mockFetchWithRetry + .mockResolvedValueOnce(notionResponse({ data_sources: [{ id: 'database-1-source-1' }] })) + .mockResolvedValueOnce(notionResponse({ data_sources: [{ id: 'database-2-source-1' }] })) + .mockResolvedValueOnce( + notionResponse({ results: [page('page-1')], has_more: false, next_cursor: null }) + ) + + await notionConnector.listDocuments( + 'token', + { scope: 'database', databaseId: ['database-1', 'database-2'] }, + providerCursor + ) + + expect(String(mockFetchWithRetry.mock.calls[2][0])).toBe( + 'https://api.notion.com/v1/data_sources/database-1-source-1/query' + ) + const queryBody = JSON.parse( + String((mockFetchWithRetry.mock.calls[2][1] as RequestInit).body) + ) as Record + expect(queryBody.start_cursor).toBe(providerCursor) + }) + + it('rejects an out-of-range production legacy database cursor', async () => { + mockFetchWithRetry + .mockResolvedValueOnce(notionResponse({ data_sources: [{ id: 'database-1-source-1' }] })) + .mockResolvedValueOnce(notionResponse({ data_sources: [{ id: 'database-2-source-1' }] })) + + await expect( + notionConnector.listDocuments( + 'token', + { scope: 'database', databaseId: ['database-1', 'database-2'] }, + JSON.stringify({ databaseIndex: 2 }) + ) + ).rejects.toThrow('Invalid Notion connector legacy database cursor') + expect(mockFetchWithRetry).toHaveBeenCalledTimes(2) + }) + + it('rejects an out-of-bounds compound data-source cursor', async () => { + mockFetchWithRetry.mockResolvedValueOnce( + notionResponse({ data_sources: [{ id: 'source-1', name: 'Primary' }] }) + ) + + await expect( + notionConnector.listDocuments( + 'token', + { scope: 'database', databaseId: 'database-1' }, + dataSourceCursor({ sourceIndex: 3 }) + ) + ).rejects.toThrow('Invalid Notion connector data-source cursor') + expect(mockFetchWithRetry).toHaveBeenCalledTimes(1) + }) + + it('does not over-fetch child pages past maxPages and marks the hidden page', async () => { + mockFetchWithRetry + .mockResolvedValueOnce( + notionResponse({ + results: [ + { id: 'child-1', type: 'child_page' }, + { id: 'child-2', type: 'child_page' }, + ], + has_more: false, + next_cursor: null, + }) + ) + .mockResolvedValueOnce(notionResponse(page('root-page'))) + .mockResolvedValueOnce(notionResponse(page('child-1'))) + const syncContext: Record = {} + + const result = await notionConnector.listDocuments( + 'token', + { scope: 'page', rootPageId: 'root-page', maxPages: '2' }, + undefined, + syncContext + ) + + expect(result.documents.map((document) => document.externalId)).toEqual([ + 'root-page', + 'child-1', + ]) + expect(mockFetchWithRetry).toHaveBeenCalledTimes(3) + expect(syncContext.listingCapped).toBe(true) + }) + + it('caps parent metadata concurrency and preserves documents after out-of-order completion', async () => { + const childIds = Array.from({ length: 5 }, (_, index) => `child-${index + 1}`) + const pending = new Map void>() + let activeRequests = 0 + let peakRequests = 0 + + mockFetchWithRetry.mockImplementation((url: string) => { + const requestUrl = String(url) + if (requestUrl.includes('/blocks/root-page/children')) { + return Promise.resolve( + notionResponse({ + results: childIds.map((id) => ({ id, type: 'child_page' })), + has_more: false, + next_cursor: null, + }) + ) + } + + const pageId = decodeURIComponent(requestUrl.split('/pages/')[1] ?? '') + activeRequests += 1 + peakRequests = Math.max(peakRequests, activeRequests) + return new Promise((resolve) => { + pending.set(pageId, (response) => { + pending.delete(pageId) + activeRequests -= 1 + resolve(response) + }) + }) + }) + + const listingPromise = notionConnector.listDocuments('token', { + scope: 'page', + rootPageId: 'root-page', + }) + + await vi.waitFor(() => { + expect([...pending.keys()].sort()).toEqual(['child-1', 'child-2', 'root-page']) + }) + pending.get('child-2')?.(notionResponse(page('child-2'))) + pending.get('root-page')?.(notionResponse(page('root-page'))) + pending.get('child-1')?.(notionResponse(page('child-1'))) + + await vi.waitFor(() => { + expect([...pending.keys()].sort()).toEqual(['child-3', 'child-4', 'child-5']) + }) + pending.get('child-5')?.(notionResponse(page('child-5'))) + pending.get('child-3')?.(notionResponse(page('child-3'))) + pending.get('child-4')?.(notionResponse(page('child-4'))) + + const result = await listingPromise + + expect(peakRequests).toBe(3) + expect(result.documents.map((document) => document.externalId)).toEqual([ + 'root-page', + 'child-1', + 'child-2', + 'child-3', + 'child-4', + 'child-5', + ]) + }) + + it('keeps all metadata failures non-authoritative under a small maxPages cap', async () => { + mockFetchWithRetry.mockResolvedValueOnce( + notionResponse({ + results: [ + { id: 'child-1', type: 'child_page' }, + { id: 'child-2', type: 'child_page' }, + { id: 'child-3', type: 'child_page' }, + ], + has_more: false, + next_cursor: null, + }) + ) + for (const pageId of ['root-page', 'child-1', 'child-2', 'child-3']) { + mockFetchWithRetry.mockResolvedValueOnce( + notionResponse( + { + object: 'error', + code: 'internal_server_error', + message: `Temporary failure for ${pageId}`, + request_id: `request-${pageId}`, + }, + 503 + ) + ) + } + const syncContext: Record = {} + + const result = await notionConnector.listDocuments( + 'token', + { scope: 'page', rootPageId: 'root-page', maxPages: '2' }, + undefined, + syncContext + ) + + expect(result.documents).toEqual([]) + expect(result.hasMore).toBe(false) + expect(mockFetchWithRetry).toHaveBeenCalledTimes(5) + expect(syncContext.listingCapped).toBe(true) + expect(syncContext.reconciliationUnsafe).toBe(true) + }) + + it('makes a parent-page listing non-authoritative when live metadata is omitted by error', async () => { + mockFetchWithRetry + .mockResolvedValueOnce( + notionResponse({ + results: [ + { id: 'child-1', type: 'child_page' }, + { id: 'child-2', type: 'child_page' }, + ], + has_more: false, + next_cursor: null, + }) + ) + .mockResolvedValueOnce(notionResponse(page('root-page'))) + .mockResolvedValueOnce( + notionResponse( + { + object: 'error', + code: 'internal_server_error', + message: 'Temporary provider failure', + request_id: 'request-child-1', + }, + 503 + ) + ) + .mockResolvedValueOnce(notionResponse(page('child-2'))) + const syncContext: Record = {} + + const result = await notionConnector.listDocuments( + 'token', + { scope: 'page', rootPageId: 'root-page' }, + undefined, + syncContext + ) + + expect(result.documents.map((document) => document.externalId)).toEqual([ + 'root-page', + 'child-2', + ]) + expect(syncContext.listingCapped).toBe(true) + expect(syncContext.reconciliationUnsafe).toBe(true) + }) + + it('makes reconciliation unsafe when listed child metadata returns an ambiguous 404', async () => { + mockFetchWithRetry + .mockResolvedValueOnce( + notionResponse({ + results: [{ id: 'child-1', type: 'child_page' }], + has_more: false, + next_cursor: null, + }) + ) + .mockResolvedValueOnce(notionResponse(page('root-page'))) + .mockResolvedValueOnce( + notionResponse( + { + object: 'error', + code: 'object_not_found', + message: 'Page not found', + request_id: 'request-child-1', + }, + 404 + ) + ) + const syncContext: Record = {} + + const result = await notionConnector.listDocuments( + 'token', + { scope: 'page', rootPageId: 'root-page' }, + undefined, + syncContext + ) + + expect(result.documents.map((document) => document.externalId)).toEqual(['root-page']) + expect(syncContext.listingCapped).toBe(true) + expect(syncContext.reconciliationUnsafe).toBe(true) + }) + + it.each(['1.5', 'Infinity', 1.5, Number.POSITIVE_INFINITY])( + 'rejects invalid persisted maxPages %s before listing from Notion', + async (maxPages) => { + await expect(notionConnector.listDocuments('token', { maxPages })).rejects.toThrow( + 'Max pages must be a positive safe integer, or 0 for unlimited' + ) + expect(mockFetchWithRetry).not.toHaveBeenCalled() + } + ) + + it.each([undefined, null, '', ' ', 0, '0'])( + 'keeps omitted or explicit unlimited maxPages %s valid at runtime', + async (maxPages) => { + mockFetchWithRetry.mockResolvedValueOnce( + notionResponse({ results: [], has_more: false, next_cursor: null }) + ) + + await expect(notionConnector.listDocuments('token', { maxPages })).resolves.toMatchObject({ + documents: [], + hasMore: false, + }) + const body = JSON.parse( + String((mockFetchWithRetry.mock.calls[0][1] as RequestInit).body) + ) as Record + expect(body.page_size).toBe(100) + } + ) + + it.each(['1.5', 'Infinity', 1.5, Number.POSITIVE_INFINITY])( + 'rejects invalid maxPages %s during validation without calling Notion', + async (maxPages) => { + await expect(notionConnector.validateConfig('token', { maxPages })).resolves.toEqual({ + valid: false, + error: 'Max pages must be a positive safe integer, or 0 for unlimited', + }) + expect(mockFetchWithRetry).not.toHaveBeenCalled() + } + ) +}) diff --git a/apps/sim/connectors/notion/notion.ts b/apps/sim/connectors/notion/notion.ts index 00ff9e1ac30..fea1c0f0e69 100644 --- a/apps/sim/connectors/notion/notion.ts +++ b/apps/sim/connectors/notion/notion.ts @@ -1,57 +1,215 @@ 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, + readBoundedHttpErrorPayload, + VALIDATE_RETRY_OPTIONS, +} from '@/lib/knowledge/documents/utils' import { notionConnectorMeta } from '@/connectors/notion/meta' import type { ConnectorConfig, ExternalDocument, ExternalDocumentList } from '@/connectors/types' -import { joinTagArray, parseMultiValue, parseTagDate } from '@/connectors/utils' +import { + CONNECTOR_MAX_FILE_BYTES, + ConnectorFileTooLargeError, + joinTagArray, + markSkipped, + parseMultiValue, + parseOptionalUnlimitedSafeInteger, + parseTagDate, + readBodyWithLimit, + sizeLimitSkipReason, +} from '@/connectors/utils' const logger = createLogger('NotionConnector') -const NOTION_API_VERSION = '2022-06-28' +const NOTION_API_VERSION = '2026-03-11' const NOTION_BASE_URL = 'https://api.notion.com/v1' +const PAGE_METADATA_CONCURRENCY = 3 +const MAX_CONFIGURED_DATABASES = 100 +const MAX_DATABASE_RESPONSE_BYTES = 1024 * 1024 +const MAX_PAGE_METADATA_RESPONSE_BYTES = 1024 * 1024 +const MAX_LIST_RESPONSE_BYTES = 16 * 1024 * 1024 +const MAX_DATA_SOURCES_PER_DATABASE = 100 +const MAX_TOTAL_DATA_SOURCES = 500 +const MAX_NOTION_UNKNOWN_BLOCK_IDS = 100 +const MAX_NOTION_MARKDOWN_RECOVERY_IDS = 200 +const MAX_NOTION_MARKDOWN_RECOVERY_REQUESTS = 200 +const NOTION_DATA_SOURCE_CURSOR_PREFIX = 'notion-data-sources:v1:' +const MAX_PAGES_VALIDATION_ERROR = 'Max pages must be a positive safe integer, or 0 for unlimited' + +interface NotionMarkdownResponse { + markdown?: unknown + truncated?: unknown + unknown_block_ids?: unknown +} -/** - * Notion allows an average of ~3 requests/second per connection, so the one - * place this connector fans out stays at or below that. - */ -const NOTION_CONCURRENCY = 3 +interface ParsedNotionMarkdownResponse { + markdown: string + truncated: boolean + unknownBlockIds: string[] + responseBytes: number +} -/** Maximum nesting depth walked by {@link fetchBlockTree}. */ -const MAX_BLOCK_DEPTH = 5 +interface NotionDataSourceReference { + id: string +} -/** Upper bound on blocks pulled for a single page, to bound time and memory. */ -const MAX_BLOCKS_PER_PAGE = 2000 +interface ResolvedNotionDataSource { + databaseId: string + dataSourceId: string +} -/** - * A Notion block with its recursively fetched children attached. - */ -interface NotionBlock extends Record { - children?: NotionBlock[] +interface NotionDataSourceCache { + databaseIds: string[] + dataSources: ResolvedNotionDataSource[] } -/** - * Per-page traversal state for {@link fetchBlockTree}. - * - * `remaining` is the block budget still available; `truncated` records that the - * walk stopped before the page was exhausted, so the cut is logged instead of - * silently shrinking the indexed content. - */ -interface BlockWalkState { - remaining: number - truncated: boolean +interface NotionDataSourceCursor { + sourceIndex: number + cursor?: string } -/** - * Block types that own their own document and must not be inlined into the - * parent page's content — they are listed and synced separately. - */ -const NON_RECURSIVE_BLOCK_TYPES = new Set(['child_page', 'child_database']) +const DATA_SOURCE_CACHE_KEY = 'notionResolvedDataSources' + +interface NotionApiErrorBody { + code?: unknown + message?: unknown + request_id?: unknown +} + +interface NotionListResponse { + results?: Record[] + has_more?: boolean + next_cursor?: string | null + request_status?: { + type?: string + incomplete_reason?: string + } +} + +function requireNotionResults( + data: NotionListResponse, + description: string +): Record[] { + if ( + !Array.isArray(data.results) || + typeof data.has_more !== 'boolean' || + !data.results.every( + (result) => isPlainRecord(result) && typeof result.id === 'string' && result.id.length > 0 + ) + ) { + throw new Error(`Notion ${description} returned a malformed results list`) + } + return data.results +} + +function isNotionPageMetadata(value: unknown): value is Record & { id: string } { + return ( + isPlainRecord(value) && + value.object === 'page' && + typeof value.id === 'string' && + value.id.length > 0 && + isPlainRecord(value.properties) && + typeof value.url === 'string' && + typeof value.last_edited_time === 'string' + ) +} + +function requireNotionPages( + values: Record[], + description: string +): (Record & { id: string })[] { + const pages = values.filter((value) => value.object === 'page') + if (!pages.every(isNotionPageMetadata)) { + throw new Error(`Notion ${description} returned malformed page metadata`) + } + return pages +} + +async function readNotionJsonObject( + response: Response, + maxBytes: number, + description: string +): Promise { + const body = await readBodyWithLimit(response, maxBytes) + if (!body) { + throw new Error(`Notion ${description} exceeds the ${maxBytes} byte limit`) + } + + try { + const parsed: unknown = JSON.parse(body.toString('utf8')) + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error('Invalid JSON object') + } + return parsed as T + } catch { + throw new Error(`Notion ${description} returned invalid JSON`) + } +} + +function parseMaxPages(value: unknown): number { + return parseOptionalUnlimitedSafeInteger(value, MAX_PAGES_VALIDATION_ERROR) +} + +class NotionApiError extends Error { + readonly status: number + readonly code?: string + readonly requestId?: string + + constructor(operation: string, status: number, code?: string, requestId?: string) { + const fields = [String(status)] + if (code) fields.push(`code=${code}`) + if (requestId) fields.push(`requestId=${requestId}`) + super(`${operation}: ${fields.join(', ')}`) + this.name = 'NotionApiError' + this.status = status + this.code = code + this.requestId = requestId + } +} + +class NotionMarkdownRecoveryLimitError extends Error { + constructor(message: string) { + super(message) + this.name = 'NotionMarkdownRecoveryLimitError' + } +} + +class NotionMarkdownIncompleteError extends Error { + constructor() { + super('Notion page contains blocks the connection cannot access and was not indexed completely') + this.name = 'NotionMarkdownIncompleteError' + } +} /** - * Container blocks whose children are rendered at the parent's indent level - * rather than one level deeper. + * Builds a bounded error from Notion's documented JSON error envelope. + * + * Only strictly validated machine identifiers are retained. The provider's + * free-form message is omitted because it can echo request values or secrets. */ -const TRANSPARENT_CONTAINER_TYPES = new Set(['table', 'column_list', 'column', 'synced_block']) +async function notionApiError(response: Response, operation: string): Promise { + let body: NotionApiErrorBody = {} + + try { + const payload = await readBoundedHttpErrorPayload(response) + if (payload.ok) { + const parsed: unknown = JSON.parse(payload.body) + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + body = parsed as NotionApiErrorBody + } + } + } catch { + body = {} + } + + const rawCode = typeof body.code === 'string' ? body.code.trim() : '' + const code = /^[a-z0-9_]{1,100}$/i.test(rawCode) ? rawCode : undefined + const rawRequestId = typeof body.request_id === 'string' ? body.request_id.trim() : '' + const requestId = /^[a-z0-9-]{1,100}$/i.test(rawRequestId) ? rawRequestId : undefined + + return new NotionApiError(operation, response.status, code, requestId) +} /** * Notion caps every paginated endpoint at 100 results. When a `maxPages` cap is @@ -76,227 +234,224 @@ function extractTitle(properties: Record): string { return 'Untitled' } -/** - * Extracts plain text from a rich_text array. - */ -function richTextToPlain(richText: Record[]): string { - return richText.map((t) => (t.plain_text as string) || '').join('') +function isPageTrashed(page: Record): boolean { + return page.in_trash === true || page.archived === true } -/** - * Renders a single block's own text, excluding its children. - * - * Covers the block types that carry no `rich_text` field (`table_row` uses - * `cells`, `child_page`/`child_database` use `title`, media blocks use - * `caption`), which would otherwise contribute nothing to the indexed content. - */ -function renderBlockSelf(type: string, blockData: Record): string { - if (type === 'code') { - const richText = blockData.rich_text as Record[] | undefined - const language = (blockData.language as string) || '' - const code = richText ? richTextToPlain(richText) : '' - return language ? `\`\`\`${language}\n${code}\n\`\`\`` : `\`\`\`\n${code}\n\`\`\`` - } +async function fetchMarkdownResponse( + accessToken: string, + pageId: string, + remainingBytes: number +): Promise { + const response = await fetchWithRetry( + `${NOTION_BASE_URL}/pages/${encodeURIComponent(pageId)}/markdown?include_transcript=true`, + { + method: 'GET', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Notion-Version': NOTION_API_VERSION, + }, + } + ) - if (type === 'equation') { - const expression = (blockData.expression as string) || '' - return expression ? `$$${expression}$$` : '' + if (!response.ok) { + throw await notionApiError(response, `Failed to fetch markdown for ${pageId}`) } - if (type === 'table_row') { - const cells = blockData.cells as Record[][] | undefined - if (!Array.isArray(cells)) return '' - const rendered = cells.map((cell) => (Array.isArray(cell) ? richTextToPlain(cell) : '')) - return rendered.some(Boolean) ? rendered.join(' | ') : '' - } + const body = await readBodyWithLimit(response, remainingBytes) + if (!body) throw new ConnectorFileTooLargeError(CONNECTOR_MAX_FILE_BYTES) - if (type === 'child_page' || type === 'child_database') { - return (blockData.title as string) || '' + let data: NotionMarkdownResponse + try { + data = JSON.parse(body.toString('utf8')) as NotionMarkdownResponse + } catch { + throw new Error(`Notion returned invalid JSON markdown for ${pageId}`) } - - if (type === 'divider') return '---' - - if (type === 'bookmark' || type === 'embed' || type === 'link_preview') { - const url = (blockData.url as string) || '' - const caption = blockData.caption as Record[] | undefined - const captionText = Array.isArray(caption) ? richTextToPlain(caption) : '' - return [captionText, url].filter(Boolean).join(' ') + if ( + typeof data.markdown !== 'string' || + typeof data.truncated !== 'boolean' || + !Array.isArray(data.unknown_block_ids) || + !data.unknown_block_ids.every((value): value is string => typeof value === 'string') + ) { + throw new Error(`Notion returned an invalid markdown response for ${pageId}`) + } + if (data.unknown_block_ids.length > MAX_NOTION_UNKNOWN_BLOCK_IDS) { + throw new NotionMarkdownRecoveryLimitError( + `Notion returned more than ${MAX_NOTION_UNKNOWN_BLOCK_IDS} recovery block IDs for one markdown response and the page was not indexed` + ) } - const richText = blockData.rich_text as Record[] | undefined - if (!richText) { - // Media/file blocks carry their only text in `caption`. - const caption = blockData.caption as Record[] | undefined - return Array.isArray(caption) ? richTextToPlain(caption) : '' + return { + markdown: data.markdown, + truncated: data.truncated, + unknownBlockIds: data.unknown_block_ids, + responseBytes: body.byteLength, } +} - const text = richTextToPlain(richText) +function richTextToPlainText(value: unknown): string { + if (!Array.isArray(value)) return '' + return value + .map((item) => { + if (!item || typeof item !== 'object') return '' + const plainText = (item as { plain_text?: unknown }).plain_text + return typeof plainText === 'string' ? plainText : '' + }) + .join('') +} - switch (type) { - case 'heading_1': - return `# ${text}` - case 'heading_2': - return `## ${text}` - case 'heading_3': - return `### ${text}` - case 'bulleted_list_item': - return `- ${text}` - case 'numbered_list_item': - return `1. ${text}` - case 'to_do': { - const checked = (blockData.checked as boolean) ? '[x]' : '[ ]' - return `${checked} ${text}` - } - case 'quote': - return `> ${text}` - default: - return text - } +function unsupportedBlockFallback(block: Record): string { + const type = typeof block.type === 'string' ? block.type : '' + const payload = type && block[type] && typeof block[type] === 'object' ? block[type] : undefined + if (!payload || Array.isArray(payload)) return '' + + const value = payload as Record + const expression = + type === 'equation' && typeof value.expression === 'string' ? value.expression : '' + const text = + richTextToPlainText(value.rich_text) || + richTextToPlainText(value.caption) || + richTextToPlainText(value.title) || + expression + const url = typeof value.url === 'string' ? value.url : '' + return [text, url].filter(Boolean).join('\n') } -/** - * Extracts plain text content from a Notion block tree, indenting nested - * children so structure survives into the indexed text. - */ -function blocksToPlainText(blocks: NotionBlock[], depth = 0): string { - const indent = ' '.repeat(depth) - const parts: string[] = [] - - for (const block of blocks) { - const type = block.type as string - const blockData = block[type] as Record | undefined - const self = blockData ? renderBlockSelf(type, blockData) : '' - - if (self) { - parts.push( - indent - ? self - .split('\n') - .map((line) => (line ? indent + line : line)) - .join('\n') - : self - ) +async function fetchUnsupportedBlockFallback( + accessToken: string, + blockId: string, + remainingBytes: number +): Promise<{ content: string; responseBytes: number }> { + const response = await fetchWithRetry( + `${NOTION_BASE_URL}/blocks/${encodeURIComponent(blockId)}`, + { + method: 'GET', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Notion-Version': NOTION_API_VERSION, + }, + } + ) + + if (!response.ok) { + const error = await notionApiError(response, `Failed to fetch unsupported block ${blockId}`) + if (error.status === 404 && error.code === 'object_not_found') { + throw new NotionMarkdownIncompleteError() } + throw error + } + + const body = await readBodyWithLimit(response, remainingBytes) + if (!body) throw new ConnectorFileTooLargeError(CONNECTOR_MAX_FILE_BYTES) - const children = block.children - if (children?.length) { - const childDepth = TRANSPARENT_CONTAINER_TYPES.has(type) ? depth : depth + 1 - const nested = blocksToPlainText(children, childDepth) - if (nested) parts.push(nested) + let block: Record + try { + const parsed: unknown = JSON.parse(body.toString('utf8')) + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error('Invalid block envelope') } + block = parsed as Record + } catch { + throw new Error(`Notion returned invalid JSON for unsupported block ${blockId}`) } - return parts.join('\n\n') + return { content: unsupportedBlockFallback(block), responseBytes: body.byteLength } } +type MarkdownRecoveryTarget = { id: string; mode: 'markdown' | 'block' } + /** - * Fetches one level of block children, handling pagination. - * - * Throws on a non-ok response rather than returning a partial level: the caller - * stores a metadata-based `contentHash`, so silently persisting truncated - * content would make the truncation permanent (the hash matches on every later - * sync and the page is never re-fetched). + * Retrieves complete enhanced markdown while following Notion's documented + * `unknown_block_ids` recovery flow. Inaccessible blocks make the hydration + * non-authoritative; unsupported markdown block types fall back to the structured + * block endpoint. Aggregate bytes and follow-up requests are bounded across the + * whole hydration, so recovery cannot become an unbounded fan-out. */ -async function fetchBlockChildren( - accessToken: string, - blockId: string, - state: BlockWalkState -): Promise { - const level: NotionBlock[] = [] - let cursor: string | undefined - let hasMore = true - - while (hasMore && state.remaining > 0) { - const params = new URLSearchParams({ - page_size: String(Math.min(100, state.remaining)), - }) - if (cursor) params.append('start_cursor', cursor) - - const response = await fetchWithRetry( - `${NOTION_BASE_URL}/blocks/${encodeURIComponent(blockId)}/children?${params.toString()}`, - { - method: 'GET', - headers: { - Authorization: `Bearer ${accessToken}`, - 'Notion-Version': NOTION_API_VERSION, - }, +async function fetchPageMarkdown(accessToken: string, pageId: string): Promise { + const pending: MarkdownRecoveryTarget[] = [{ id: pageId, mode: 'markdown' }] + const queued = new Set([`markdown:${pageId}`]) + const recoveryIds = new Set() + const visited = new Set() + const markdownParts: string[] = [] + let pendingIndex = 0 + let totalResponseBytes = 0 + let recoveryRequests = 0 + + const enqueue = (target: MarkdownRecoveryTarget) => { + if (target.id !== pageId && !recoveryIds.has(target.id)) { + recoveryIds.add(target.id) + if (recoveryIds.size > MAX_NOTION_MARKDOWN_RECOVERY_IDS) { + throw new NotionMarkdownRecoveryLimitError( + `Notion page requires more than ${MAX_NOTION_MARKDOWN_RECOVERY_IDS} unique markdown recovery IDs and was not indexed` + ) } - ) - - if (!response.ok) { - throw new Error(`Failed to fetch blocks for ${blockId}: ${response.status}`) } - const data = await response.json() - const results = (data.results || []) as NotionBlock[] - level.push(...results) - state.remaining -= results.length - cursor = data.next_cursor ?? undefined - hasMore = data.has_more === true + const key = `${target.mode}:${target.id}` + if (queued.has(key)) return + queued.add(key) + pending.push(target) } - if (hasMore) state.truncated = true - - return level -} + while (pendingIndex < pending.length) { + const target = pending[pendingIndex++] + const visitKey = `${target.mode}:${target.id}` + if (visited.has(visitKey)) continue + visited.add(visitKey) + + if (target.id !== pageId || target.mode !== 'markdown') { + recoveryRequests++ + if (recoveryRequests > MAX_NOTION_MARKDOWN_RECOVERY_REQUESTS) { + throw new NotionMarkdownRecoveryLimitError( + `Notion page requires more than ${MAX_NOTION_MARKDOWN_RECOVERY_REQUESTS} markdown recovery requests and was not indexed` + ) + } + } -/** - * Recursively fetches a page's block tree. - * - * `/v1/blocks/{id}/children` returns only the first level of children, so any - * block with `has_children: true` (toggles, callouts, columns, tables, nested - * lists) must be expanded with a further request or its content is lost. - * Recursion is bounded by {@link MAX_BLOCK_DEPTH} and {@link MAX_BLOCKS_PER_PAGE}. - * - * The walk is sequential. Notion allows an average of ~3 requests/second per - * connection, so parallelising the expansion only converts into 429s and - * backoff — and a per-level fan-out would compound to `n^depth` requests - * in flight, which is far past that limit. - */ -async function fetchBlockTree( - accessToken: string, - blockId: string, - depth: number, - state: BlockWalkState -): Promise { - if (depth > MAX_BLOCK_DEPTH || state.remaining <= 0) { - state.truncated = true - return [] - } + const remainingBytes = CONNECTOR_MAX_FILE_BYTES - totalResponseBytes + if (remainingBytes <= 0) throw new ConnectorFileTooLargeError(CONNECTOR_MAX_FILE_BYTES) - const level = await fetchBlockChildren(accessToken, blockId, state) + if (target.mode === 'block') { + const fallback = await fetchUnsupportedBlockFallback(accessToken, target.id, remainingBytes) + totalResponseBytes += fallback.responseBytes + if (fallback.content) markdownParts.push(fallback.content) + continue + } - for (const block of level) { - if (block.has_children !== true) continue - if (NON_RECURSIVE_BLOCK_TYPES.has(block.type as string)) continue - if (state.remaining <= 0) { - state.truncated = true - break + let markdownResponse: ParsedNotionMarkdownResponse + try { + markdownResponse = await fetchMarkdownResponse(accessToken, target.id, remainingBytes) + } catch (error) { + if (target.id !== pageId && error instanceof NotionApiError) { + if (error.status === 404 && error.code === 'object_not_found') { + throw new NotionMarkdownIncompleteError() + } + if (error.status === 400 && error.code === 'validation_error') { + enqueue({ id: target.id, mode: 'block' }) + continue + } + } + throw error } - block.children = await fetchBlockTree(accessToken, block.id as string, depth + 1, state) - } - return level -} + totalResponseBytes += markdownResponse.responseBytes + if (markdownResponse.markdown) markdownParts.push(markdownResponse.markdown) -/** - * Fetches the complete block tree for a page, logging when the traversal is cut - * short by the depth or block bounds so truncation is never silent. - */ -async function fetchAllBlocks(accessToken: string, pageId: string): Promise { - const state: BlockWalkState = { remaining: MAX_BLOCKS_PER_PAGE, truncated: false } - const blocks = await fetchBlockTree(accessToken, pageId, 0, state) - - if (state.truncated) { - logger.warn('Notion page content truncated during block walk', { - pageId, - maxBlocks: MAX_BLOCKS_PER_PAGE, - maxDepth: MAX_BLOCK_DEPTH, - blocksFetched: MAX_BLOCKS_PER_PAGE - state.remaining, - }) + if (markdownResponse.truncated && markdownResponse.unknownBlockIds.length === 0) { + throw new NotionMarkdownRecoveryLimitError( + 'Notion returned truncated markdown without recovery block IDs and the page was not indexed' + ) + } + + for (const unknownBlockId of markdownResponse.unknownBlockIds) { + enqueue({ + id: unknownBlockId, + mode: markdownResponse.truncated ? 'markdown' : 'block', + }) + } } - return blocks + return markdownParts.join('\n\n') } /** @@ -340,14 +495,14 @@ function pageToStub(page: Record): ExternalDocument { mimeType: 'text/plain', sourceUrl: url, /** - * The `v2` namespace is a one-time invalidation. The hash is metadata-only, + * The `v3` namespace is a one-time invalidation. The hash is metadata-only, * so a stored page whose `last_edited_time` has not moved is classified - * `unchanged` and never re-hydrated — meaning it would keep the truncated - * single-level block content indexed before recursive block fetching landed - * (tables in particular were indexed empty). Bumping the namespace forces - * one re-hydration per page, after which normal hash gating resumes. + * `unchanged` and never re-hydrated — meaning it would keep the incomplete + * single-level block rendering used before Notion's complete-page markdown + * endpoint was adopted. The scoped bump forces one re-hydration per page, + * after which normal hash gating resumes. */ - contentHash: `notion:v2:${pageId}:${lastEditedTime}`, + contentHash: `notion:v3:${pageId}:${lastEditedTime}`, metadata: { tags, lastModified: page.last_edited_time as string, @@ -369,7 +524,7 @@ export const notionConnector: ConnectorConfig = { const scope = (sourceConfig.scope as string) || 'workspace' const databaseIds = parseMultiValue(sourceConfig.databaseId) const rootPageId = (sourceConfig.rootPageId as string)?.trim() - const maxPages = sourceConfig.maxPages ? Number(sourceConfig.maxPages) : 0 + const maxPages = parseMaxPages(sourceConfig.maxPages) if (scope === 'database' && databaseIds.length > 0) { return listFromDatabases(accessToken, databaseIds, maxPages, cursor, syncContext) @@ -402,23 +557,51 @@ export const notionConnector: ConnectorConfig = { ) if (!response.ok) { - if (response.status === 404) return null - throw new Error(`Failed to get Notion page: ${response.status}`) + throw await notionApiError(response, 'Failed to get Notion page') } - const page = await response.json() - if (page.archived) return null + const page = await readNotionJsonObject>( + response, + MAX_PAGE_METADATA_RESPONSE_BYTES, + `page ${externalId} metadata` + ) + if (!isNotionPageMetadata(page) || page.id !== externalId) { + throw new Error(`Notion page ${externalId} returned malformed metadata`) + } + if (isPageTrashed(page)) return null /** - * A block-fetch failure propagates rather than degrading to `null`. The - * stored `contentHash` is metadata-based, so persisting a partial page - * would freeze the truncation in place; a thrown error is instead recorded - * by the sync engine as a document failure and retried on the next sync. + * Incomplete markdown responses propagate rather than becoming successful + * partial documents. The stored content hash is metadata-based, so persisting + * a partial response would otherwise prevent recovery until the next edit. */ - const blocks = await fetchAllBlocks(accessToken, externalId) - const blockContent = blocksToPlainText(blocks) const stub = pageToStub(page) - const content = blockContent.trim() || stub.title + let markdown: string + try { + markdown = await fetchPageMarkdown(accessToken, externalId) + } catch (error) { + if (error instanceof ConnectorFileTooLargeError) { + return markSkipped(stub, sizeLimitSkipReason(error.limitBytes)) + } + if (error instanceof NotionMarkdownRecoveryLimitError) { + return markSkipped(stub, error.message) + } + if (error instanceof NotionMarkdownIncompleteError) { + return { + ...markSkipped(stub, error.message), + /** + * Access to a nested block can change without moving the parent page's + * `last_edited_time`. Supply a connector-owned retry marker for the sync + * engine to persist instead of the listing hash, so the next listing + * classifies this document as changed and retries hydration. Existing + * indexed content remains last-known-good. + */ + skippedRetryContentHash: `notion:retry:v1:${stub.externalId}`, + } + } + throw error + } + const content = markdown.trim() || stub.title return { ...stub, content, contentDeferred: false } }, @@ -429,10 +612,10 @@ export const notionConnector: ConnectorConfig = { const scope = (sourceConfig.scope as string) || 'workspace' const databaseIds = parseMultiValue(sourceConfig.databaseId) const rootPageId = (sourceConfig.rootPageId as string)?.trim() - const maxPages = sourceConfig.maxPages as string | undefined - - if (maxPages && (Number.isNaN(Number(maxPages)) || Number(maxPages) <= 0)) { - return { valid: false, error: 'Max pages must be a positive number' } + try { + parseMaxPages(sourceConfig.maxPages) + } catch (error) { + return { valid: false, error: getErrorMessage(error, MAX_PAGES_VALIDATION_ERROR) } } if (scope === 'database' && databaseIds.length === 0) { @@ -447,28 +630,8 @@ export const notionConnector: ConnectorConfig = { } try { - // Verify the token works if (scope === 'database' && databaseIds.length > 0) { - // Verify every database is accessible - for (const databaseId of databaseIds) { - const response = await fetchWithRetry( - `${NOTION_BASE_URL}/databases/${encodeURIComponent(databaseId)}`, - { - method: 'GET', - headers: { - Authorization: `Bearer ${accessToken}`, - 'Notion-Version': NOTION_API_VERSION, - }, - }, - VALIDATE_RETRY_OPTIONS - ) - if (!response.ok) { - return { - valid: false, - error: `Cannot access database ${databaseId}: ${response.status}`, - } - } - } + await resolveDatabaseDataSources(accessToken, databaseIds, VALIDATE_RETRY_OPTIONS) } else if (scope === 'page' && rootPageId) { // Verify page is accessible const response = await fetchWithRetry( @@ -483,7 +646,8 @@ export const notionConnector: ConnectorConfig = { VALIDATE_RETRY_OPTIONS ) if (!response.ok) { - return { valid: false, error: `Cannot access page: ${response.status}` } + const error = await notionApiError(response, 'Cannot access page') + return { valid: false, error: error.message } } } else { // Workspace scope — just verify token works @@ -501,8 +665,8 @@ export const notionConnector: ConnectorConfig = { VALIDATE_RETRY_OPTIONS ) if (!response.ok) { - const errorText = await response.text() - return { valid: false, error: `Cannot access Notion workspace: ${errorText}` } + const error = await notionApiError(response, 'Cannot access Notion workspace') + return { valid: false, error: error.message } } } @@ -566,21 +730,28 @@ async function listFromWorkspace( }) if (!response.ok) { - const errorText = await response.text() - logger.error('Failed to search Notion', { status: response.status, error: errorText }) - throw new Error(`Failed to search Notion: ${response.status}`) + const error = await notionApiError(response, 'Failed to search Notion') + logger.error('Failed to search Notion', { error: error.message }) + throw error } - const data = await response.json() - const results = (data.results || []) as Record[] - const pages = results.filter((r) => r.object === 'page' && !(r.archived as boolean)) + const data = await readNotionJsonObject( + response, + MAX_LIST_RESPONSE_BYTES, + 'workspace search response' + ) + const results = requireNotionResults(data, 'workspace search') + const pages = requireNotionPages(results, 'workspace search').filter( + (result) => !isPageTrashed(result) + ) const documents = pages.map(pageToStub) const totalFetched = ((syncContext?.totalDocsFetched as number) ?? 0) + documents.length if (syncContext) syncContext.totalDocsFetched = totalFetched const hitLimit = maxPages > 0 && totalFetched >= maxPages - if (hitLimit && syncContext) syncContext.listingCapped = true + const sourceHasMore = data.has_more === true + if (hitLimit && sourceHasMore && syncContext) syncContext.listingCapped = true const nextCursor = hitLimit ? undefined : ((data.next_cursor as string) ?? undefined) @@ -588,25 +759,224 @@ async function listFromWorkspace( documents, nextCursor, hasMore: hitLimit ? false : data.has_more === true, + /** Notion documents that workspace search is not an exhaustive enumeration. */ + reconciliationSafe: false, } } /** - * Lists pages from one or more Notion databases. - * - * Notion's `/v1/databases/{database_id}/query` endpoint is per-database — there - * is no batch query endpoint — so multiple databases are walked sequentially. - * - * Cursor format: - * - Single database: the Notion `start_cursor` string directly, or undefined. - * - Multiple databases: JSON-encoded `{ databaseIndex, cursor }` where - * `databaseIndex` is the position into `databaseIds` currently being drained - * and `cursor` is the Notion `start_cursor` for that database (or undefined - * when starting a fresh database). - * - * Page IDs returned by Notion are globally-unique UUIDs, so each page's - * `externalId` does not need to be namespaced by database. + * Resolves every current data source owned by the configured database IDs. This + * preserves existing connector configuration while using Notion's post-2025 + * data model, where one database may contain multiple independently queried + * data sources. */ +async function resolveDatabaseDataSources( + accessToken: string, + databaseIds: string[], + retryOptions?: typeof VALIDATE_RETRY_OPTIONS +): Promise { + if (databaseIds.length > MAX_CONFIGURED_DATABASES) { + throw new Error(`Notion connector supports at most ${MAX_CONFIGURED_DATABASES} databases`) + } + + const resolved: ResolvedNotionDataSource[] = [] + + for (const databaseId of databaseIds) { + const response = await fetchWithRetry( + `${NOTION_BASE_URL}/databases/${encodeURIComponent(databaseId)}`, + { + method: 'GET', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Notion-Version': NOTION_API_VERSION, + }, + }, + retryOptions + ) + + if (!response.ok) { + throw await notionApiError(response, `Cannot access database ${databaseId}`) + } + + const database = await readNotionJsonObject>( + response, + MAX_DATABASE_RESPONSE_BYTES, + `database ${databaseId} metadata` + ) + + const rawReferences = Array.isArray(database.data_sources) ? database.data_sources : [] + if (rawReferences.length > MAX_DATA_SOURCES_PER_DATABASE) { + throw new Error( + `Notion database ${databaseId} exposes more than ${MAX_DATA_SOURCES_PER_DATABASE} data sources` + ) + } + + const references = rawReferences.flatMap((value): NotionDataSourceReference[] => { + if (!value || typeof value !== 'object') return [] + const id = (value as { id?: unknown }).id + return typeof id === 'string' && id ? [{ id }] : [] + }) + + if (references.length === 0) { + throw new Error(`Notion database ${databaseId} has no queryable data sources`) + } + if (resolved.length + references.length > MAX_TOTAL_DATA_SOURCES) { + throw new Error(`Notion connector supports at most ${MAX_TOTAL_DATA_SOURCES} data sources`) + } + + resolved.push(...references.map(({ id }) => ({ databaseId, dataSourceId: id }))) + } + + return resolved +} + +function readCachedDataSources( + syncContext: Record | undefined, + databaseIds: string[] +): ResolvedNotionDataSource[] | undefined { + const cached = syncContext?.[DATA_SOURCE_CACHE_KEY] + if (!cached || typeof cached !== 'object') return undefined + + const value = cached as Partial + if ( + !Array.isArray(value.databaseIds) || + !value.databaseIds.every((id): id is string => typeof id === 'string') || + value.databaseIds.length !== databaseIds.length || + !value.databaseIds.every((id, index) => id === databaseIds[index]) || + !Array.isArray(value.dataSources) || + value.dataSources.length > MAX_TOTAL_DATA_SOURCES + ) { + return undefined + } + + const dataSources = value.dataSources.flatMap((source): ResolvedNotionDataSource[] => { + if (!source || typeof source !== 'object') return [] + const candidate = source as Partial + return typeof candidate.databaseId === 'string' && typeof candidate.dataSourceId === 'string' + ? [{ databaseId: candidate.databaseId, dataSourceId: candidate.dataSourceId }] + : [] + }) + + if (dataSources.length !== value.dataSources.length) return undefined + + const configuredDatabaseIds = new Set(databaseIds) + const countsByDatabase = new Map() + for (const source of dataSources) { + if (!configuredDatabaseIds.has(source.databaseId)) return undefined + const count = (countsByDatabase.get(source.databaseId) ?? 0) + 1 + if (count > MAX_DATA_SOURCES_PER_DATABASE) return undefined + countsByDatabase.set(source.databaseId, count) + } + + return dataSources +} + +async function resolveDatabaseDataSourcesForSync( + accessToken: string, + databaseIds: string[], + syncContext?: Record +): Promise { + const cached = readCachedDataSources(syncContext, databaseIds) + if (cached) return cached + + const dataSources = await resolveDatabaseDataSources(accessToken, databaseIds) + if (syncContext) { + syncContext[DATA_SOURCE_CACHE_KEY] = { + databaseIds: [...databaseIds], + dataSources: dataSources.map((source) => ({ ...source })), + } satisfies NotionDataSourceCache + } + return dataSources +} + +function encodeDataSourceCursor(cursor: NotionDataSourceCursor): string { + return `${NOTION_DATA_SOURCE_CURSOR_PREFIX}${encodeURIComponent(JSON.stringify(cursor))}` +} + +function decodeLegacyDatabaseCursor( + cursor: string, + databaseIds: string[], + dataSources: ResolvedNotionDataSource[] +): NotionDataSourceCursor | undefined { + if (databaseIds.length <= 1) return undefined + + let parsed: unknown + try { + parsed = JSON.parse(cursor) as unknown + } catch { + return undefined + } + + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return undefined + const keys = Object.keys(parsed) + if ( + !keys.includes('databaseIndex') || + keys.some((key) => key !== 'databaseIndex' && key !== 'cursor') + ) { + return undefined + } + + const value = parsed as { databaseIndex?: unknown; cursor?: unknown } + if ( + !Number.isSafeInteger(value.databaseIndex) || + Number(value.databaseIndex) < 0 || + (value.cursor !== undefined && typeof value.cursor !== 'string') + ) { + return undefined + } + + const databaseIndex = Number(value.databaseIndex) + if (databaseIndex >= databaseIds.length) { + throw new Error('Invalid Notion connector legacy database cursor') + } + const databaseId = databaseIds[databaseIndex] + const sourceIndex = dataSources.findIndex((source) => source.databaseId === databaseId) + if (sourceIndex < 0) { + throw new Error('Invalid Notion connector legacy database cursor') + } + + return { sourceIndex, cursor: value.cursor as string | undefined } +} + +function decodeDataSourceCursor( + cursor: string, + databaseIds: string[], + dataSources: ResolvedNotionDataSource[] +): NotionDataSourceCursor { + if (!cursor.startsWith(NOTION_DATA_SOURCE_CURSOR_PREFIX)) { + return ( + decodeLegacyDatabaseCursor(cursor, databaseIds, dataSources) ?? { sourceIndex: 0, cursor } + ) + } + + let parsed: unknown + try { + parsed = JSON.parse( + decodeURIComponent(cursor.slice(NOTION_DATA_SOURCE_CURSOR_PREFIX.length)) + ) as unknown + } catch { + throw new Error('Invalid Notion connector data-source cursor') + } + + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error('Invalid Notion connector data-source cursor') + } + const value = parsed as { sourceIndex?: unknown; cursor?: unknown } + if ( + !Number.isSafeInteger(value.sourceIndex) || + Number(value.sourceIndex) < 0 || + Number(value.sourceIndex) >= dataSources.length || + (value.cursor !== undefined && typeof value.cursor !== 'string') + ) { + throw new Error('Invalid Notion connector data-source cursor') + } + + return { + sourceIndex: Number(value.sourceIndex), + cursor: value.cursor as string | undefined, + } +} + async function listFromDatabases( accessToken: string, databaseIds: string[], @@ -614,54 +984,40 @@ async function listFromDatabases( cursor?: string, syncContext?: Record ): Promise { - let databaseIndex = 0 + const dataSources = await resolveDatabaseDataSourcesForSync(accessToken, databaseIds, syncContext) + let sourceIndex = 0 let startCursor: string | undefined if (cursor) { - if (databaseIds.length === 1) { - // Single-database path: cursor is always a bare Notion `next_cursor` string, - // matching the legacy pre-multi-select format. Never JSON-decode here. - startCursor = cursor - } else { - try { - const parsed = JSON.parse(cursor) as unknown - if ( - parsed && - typeof parsed === 'object' && - typeof (parsed as { databaseIndex?: unknown }).databaseIndex === 'number' - ) { - const compound = parsed as { databaseIndex: number; cursor?: string } - databaseIndex = compound.databaseIndex - startCursor = typeof compound.cursor === 'string' ? compound.cursor : undefined - } else { - // Legacy single-DB cursor carried forward into a now-multi-DB config: - // treat it as the start cursor for the first database. - startCursor = cursor - } - } catch { - startCursor = cursor - } - } + const decoded = decodeDataSourceCursor(cursor, databaseIds, dataSources) + sourceIndex = decoded.sourceIndex + startCursor = decoded.cursor + } + + if (!Number.isSafeInteger(sourceIndex) || sourceIndex < 0 || sourceIndex >= dataSources.length) { + throw new Error('Invalid Notion connector data-source cursor') } const documents: ExternalDocument[] = [] let nextCursor: string | undefined let hasMore = false + let queryResultIncomplete = false - while (databaseIndex < databaseIds.length) { - const databaseId = databaseIds[databaseIndex] + if (sourceIndex < dataSources.length) { + const { databaseId, dataSourceId } = dataSources[sourceIndex] const body: Record = { page_size: pageSizeFor(maxPages, syncContext) } if (startCursor) body.start_cursor = startCursor - logger.info('Querying Notion database', { + logger.info('Querying Notion data source', { databaseId, - databaseIndex, - databaseCount: databaseIds.length, + dataSourceId, + sourceIndex, + sourceCount: dataSources.length, startCursor, }) const response = await fetchWithRetry( - `${NOTION_BASE_URL}/databases/${encodeURIComponent(databaseId)}/query`, + `${NOTION_BASE_URL}/data_sources/${encodeURIComponent(dataSourceId)}/query`, { method: 'POST', headers: { @@ -674,44 +1030,59 @@ async function listFromDatabases( ) if (!response.ok) { - const errorText = await response.text() - logger.error('Failed to query Notion database', { + const error = await notionApiError( + response, + `Failed to query Notion data source ${dataSourceId}` + ) + logger.error('Failed to query Notion data source', { databaseId, - status: response.status, - error: errorText, + dataSourceId, + error: error.message, }) - throw new Error(`Failed to query Notion database ${databaseId}: ${response.status}`) + throw error } - const data = await response.json() - const results = (data.results || []) as Record[] - const pages = results.filter((r) => r.object === 'page' && !(r.archived as boolean)) + const data = await readNotionJsonObject( + response, + MAX_LIST_RESPONSE_BYTES, + `data source ${dataSourceId} query response` + ) + const results = requireNotionResults(data, `data source ${dataSourceId} query`) + const pages = requireNotionPages(results, `data source ${dataSourceId} query`).filter( + (result) => !isPageTrashed(result) + ) documents.push(...pages.map(pageToStub)) - if (data.has_more === true && typeof data.next_cursor === 'string') { - const nextStart = data.next_cursor as string - nextCursor = - databaseIds.length === 1 ? nextStart : JSON.stringify({ databaseIndex, cursor: nextStart }) - hasMore = true - break + queryResultIncomplete = + data.request_status?.type === 'incomplete' && + data.request_status?.incomplete_reason === 'query_result_limit_reached' + const providerCursor = + typeof data.next_cursor === 'string' && data.next_cursor.trim().length > 0 + ? data.next_cursor + : undefined + const paginationCursorMissing = data.has_more === true && providerCursor === undefined + + if ((queryResultIncomplete || paginationCursorMissing) && syncContext) { + syncContext.listingCapped = true + syncContext.reconciliationUnsafe = true } - databaseIndex++ - startCursor = undefined - - if (databaseIndex < databaseIds.length) { - nextCursor = - databaseIds.length === 1 ? undefined : JSON.stringify({ databaseIndex, cursor: undefined }) + if (data.has_more === true && providerCursor !== undefined) { + nextCursor = encodeDataSourceCursor({ sourceIndex, cursor: providerCursor }) + hasMore = true + } else if (!paginationCursorMissing && sourceIndex + 1 < dataSources.length) { + nextCursor = encodeDataSourceCursor({ sourceIndex: sourceIndex + 1 }) hasMore = true - break } + + if (paginationCursorMissing) queryResultIncomplete = true } const totalFetched = ((syncContext?.totalDocsFetched as number) ?? 0) + documents.length if (syncContext) syncContext.totalDocsFetched = totalFetched const hitLimit = maxPages > 0 && totalFetched >= maxPages if (hitLimit) { - if (syncContext) syncContext.listingCapped = true + if (hasMore && syncContext) syncContext.listingCapped = true hasMore = false nextCursor = undefined } @@ -720,6 +1091,8 @@ async function listFromDatabases( documents, nextCursor: hasMore ? nextCursor : undefined, hasMore, + reconciliationSafe: + queryResultIncomplete || syncContext?.reconciliationUnsafe === true ? false : undefined, } } @@ -757,13 +1130,17 @@ async function listFromParentPage( ) if (!response.ok) { - const errorText = await response.text() - logger.error('Failed to list child blocks', { status: response.status, error: errorText }) - throw new Error(`Failed to list child blocks: ${response.status}`) + const error = await notionApiError(response, 'Failed to list child blocks') + logger.error('Failed to list child blocks', { error: error.message }) + throw error } - const data = await response.json() - const blockResults = (data.results || []) as Record[] + const data = await readNotionJsonObject( + response, + MAX_LIST_RESPONSE_BYTES, + `page ${rootPageId} child-block response` + ) + const blockResults = requireNotionResults(data, `page ${rootPageId} child-block listing`) // Filter to child_page blocks only (child_database blocks cannot be fetched via the Pages API) const childPageIds = blockResults @@ -773,18 +1150,21 @@ async function listFromParentPage( // Also include the root page itself on the first call (no cursor) const pageIdsToFetch = !cursor ? [rootPageId, ...childPageIds] : childPageIds - // Fetch page metadata (not content) in concurrent batches to build stubs. - // A page dropped by a transient error still exists in Notion, so the listing - // is incomplete and deletion reconciliation must be suppressed — otherwise the - // sync engine hard-deletes the stored document. A 404 is genuine absence and - // does not cap the listing. const documents: ExternalDocument[] = [] + /** + * A child metadata failure makes this listing non-authoritative. Notion uses + * `object_not_found` for lost access too, so even a 404 cannot prove deletion. + */ let droppedByError = false + let pageIdsProcessed = 0 - for (let i = 0; i < pageIdsToFetch.length; i += NOTION_CONCURRENCY) { + for (let i = 0; i < pageIdsToFetch.length; ) { const cumulativeSoFar = ((syncContext?.totalDocsFetched as number) ?? 0) + documents.length if (maxPages > 0 && cumulativeSoFar >= maxPages) break - const batch = pageIdsToFetch.slice(i, i + NOTION_CONCURRENCY) + const remainingBudget = maxPages > 0 ? maxPages - cumulativeSoFar : PAGE_METADATA_CONCURRENCY + const batch = pageIdsToFetch.slice(i, i + Math.min(PAGE_METADATA_CONCURRENCY, remainingBudget)) + i += batch.length + pageIdsProcessed += batch.length const results = await Promise.all( batch.map(async (pageId) => { try { @@ -799,12 +1179,20 @@ async function listFromParentPage( } ) if (!pageResponse.ok) { - if (pageResponse.status !== 404) droppedByError = true - logger.warn(`Failed to fetch child page ${pageId}`, { status: pageResponse.status }) + droppedByError = true + const error = await notionApiError(pageResponse, `Failed to fetch child page ${pageId}`) + logger.warn('Failed to fetch child page', { pageId, error: error.message }) return null } - const page = await pageResponse.json() - if (page.archived) return null + const page = await readNotionJsonObject>( + pageResponse, + MAX_PAGE_METADATA_RESPONSE_BYTES, + `page ${pageId} metadata` + ) + if (!isNotionPageMetadata(page) || page.id !== pageId) { + throw new Error(`Notion page ${pageId} returned malformed or mismatched metadata`) + } + if (isPageTrashed(page)) return null return pageToStub(page) } catch (error) { droppedByError = true @@ -818,12 +1206,22 @@ async function listFromParentPage( documents.push(...(results.filter(Boolean) as ExternalDocument[])) } - if (droppedByError && syncContext) syncContext.listingCapped = true + if (droppedByError && syncContext) { + /** + * A provider failure omitted a page that may still exist. `listingCapped` + * alone is insufficient because a forced full sync may override a configured + * cap; `reconciliationUnsafe` is absolute and prevents deletion against this + * non-authoritative listing in every sync mode. + */ + syncContext.listingCapped = true + syncContext.reconciliationUnsafe = true + } const totalFetched = ((syncContext?.totalDocsFetched as number) ?? 0) + documents.length if (syncContext) syncContext.totalDocsFetched = totalFetched const hitLimit = maxPages > 0 && totalFetched >= maxPages - if (hitLimit && syncContext) syncContext.listingCapped = true + const sourceHasMore = data.has_more === true || pageIdsProcessed < pageIdsToFetch.length + if (hitLimit && sourceHasMore && syncContext) syncContext.listingCapped = true const nextCursor = hitLimit ? undefined : ((data.next_cursor as string) ?? undefined) diff --git a/apps/sim/connectors/onedrive/onedrive.test.ts b/apps/sim/connectors/onedrive/onedrive.test.ts index a6236484ee8..b086b60f058 100644 --- a/apps/sim/connectors/onedrive/onedrive.test.ts +++ b/apps/sim/connectors/onedrive/onedrive.test.ts @@ -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) { 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 = {} + + 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 = { [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', + }) + }) }) diff --git a/apps/sim/connectors/onedrive/onedrive.ts b/apps/sim/connectors/onedrive/onedrive.ts index 6e25767f5ef..bf1c6196b3a 100644 --- a/apps/sim/connectors/onedrive/onedrive.ts +++ b/apps/sim/connectors/onedrive/onedrive.ts @@ -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 + remoteItem?: Record 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 - 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 => { 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 ): 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 { diff --git a/apps/sim/connectors/sharepoint/sharepoint.test.ts b/apps/sim/connectors/sharepoint/sharepoint.test.ts index 8c0def7b1be..53e8cfebfc9 100644 --- a/apps/sim/connectors/sharepoint/sharepoint.test.ts +++ b/apps/sim/connectors/sharepoint/sharepoint.test.ts @@ -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) { 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', () => { diff --git a/apps/sim/connectors/sharepoint/sharepoint.ts b/apps/sim/connectors/sharepoint/sharepoint.ts index 8a7fb352d29..6eff64a8f21 100644 --- a/apps/sim/connectors/sharepoint/sharepoint.ts +++ b/apps/sim/connectors/sharepoint/sharepoint.ts @@ -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 + remoteItem?: Record 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[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 { +): 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 + 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 { diff --git a/apps/sim/connectors/types.ts b/apps/sim/connectors/types.ts index f16984208c3..9ab557d63a8 100644 --- a/apps/sim/connectors/types.ts +++ b/apps/sim/connectors/types.ts @@ -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 } @@ -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 } diff --git a/apps/sim/connectors/utils.test.ts b/apps/sim/connectors/utils.test.ts index ca786fadc1d..4fd76920f55 100644 --- a/apps/sim/connectors/utils.test.ts +++ b/apps/sim/connectors/utils.test.ts @@ -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', diff --git a/apps/sim/connectors/utils.ts b/apps/sim/connectors/utils.ts index 63674ae350c..8672c1ecb62 100644 --- a/apps/sim/connectors/utils.ts +++ b/apps/sim/connectors/utils.ts @@ -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 + folder?: Record + package?: Record + remoteItem?: Record +} + +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 + 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 = { amp: '&', @@ -369,25 +591,15 @@ export async function readBodyWithLimit( response: Response | SecureFetchResponse, maxBytes: number ): Promise { - if (!response.body) { - const buffer = Buffer.from(await response.arrayBuffer()) - return buffer.byteLength > maxBytes ? null : buffer - } - - 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) + try { + return await readResponseToBufferWithLimit(response, { + maxBytes, + label: 'Connector file download', + }) + } catch (error) { + if (isPayloadSizeLimitError(error)) return null + throw error } - return Buffer.concat(chunks) } /** diff --git a/apps/sim/lib/api/contracts/knowledge/connectors.ts b/apps/sim/lib/api/contracts/knowledge/connectors.ts index d872cff4203..5261106be51 100644 --- a/apps/sim/lib/api/contracts/knowledge/connectors.ts +++ b/apps/sim/lib/api/contracts/knowledge/connectors.ts @@ -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(), }) diff --git a/apps/sim/lib/api/contracts/v2/knowledge.ts b/apps/sim/lib/api/contracts/v2/knowledge.ts index 8743287578d..90ae85e4f81 100644 --- a/apps/sim/lib/api/contracts/v2/knowledge.ts +++ b/apps/sim/lib/api/contracts/v2/knowledge.ts @@ -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.'), }) diff --git a/apps/sim/lib/atlassian/discovery.test.ts b/apps/sim/lib/atlassian/discovery.test.ts index aadef8bcc4e..d3c3f4b34bc 100644 --- a/apps/sim/lib/atlassian/discovery.test.ts +++ b/apps/sim/lib/atlassian/discovery.test.ts @@ -19,8 +19,8 @@ function options(over: Record = {}) { >[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/ ) diff --git a/apps/sim/lib/chunkers/chunk-budget.ts b/apps/sim/lib/chunkers/chunk-budget.ts new file mode 100644 index 00000000000..de7e1ff8e3d --- /dev/null +++ b/apps/sim/lib/chunkers/chunk-budget.ts @@ -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(target: T[], value: T): void { + if (this.maxChunks !== undefined && this.produced >= this.maxChunks) { + throw new ChunkLimitExceededError(this.maxChunks) + } + target.push(value) + this.produced++ + } +} diff --git a/apps/sim/lib/chunkers/chunk-limit.test.ts b/apps/sim/lib/chunkers/chunk-limit.test.ts new file mode 100644 index 00000000000..12761f3bb51 --- /dev/null +++ b/apps/sim/lib/chunkers/chunk-limit.test.ts @@ -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) + }) +}) diff --git a/apps/sim/lib/chunkers/docs-chunker.test.ts b/apps/sim/lib/chunkers/docs-chunker.test.ts index fb299116ac4..cacd431c02f 100644 --- a/apps/sim/lib/chunkers/docs-chunker.test.ts +++ b/apps/sim/lib/chunkers/docs-chunker.test.ts @@ -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) + }) +}) diff --git a/apps/sim/lib/chunkers/docs-chunker.ts b/apps/sim/lib/chunkers/docs-chunker.ts index 3d3af5d8613..cbd0b581ee7 100644 --- a/apps/sim/lib/chunkers/docs-chunker.ts +++ b/apps/sim/lib/chunkers/docs-chunker.ts @@ -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 } } diff --git a/apps/sim/lib/chunkers/index.ts b/apps/sim/lib/chunkers/index.ts index 2e4595b5ea0..88d62c91c98 100644 --- a/apps/sim/lib/chunkers/index.ts +++ b/apps/sim/lib/chunkers/index.ts @@ -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' diff --git a/apps/sim/lib/chunkers/json-yaml-chunker.test.ts b/apps/sim/lib/chunkers/json-yaml-chunker.test.ts index 8ac0eafada8..66d49cb967a 100644 --- a/apps/sim/lib/chunkers/json-yaml-chunker.test.ts +++ b/apps/sim/lib/chunkers/json-yaml-chunker.test.ts @@ -269,6 +269,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 +304,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 () => { diff --git a/apps/sim/lib/chunkers/json-yaml-chunker.ts b/apps/sim/lib/chunkers/json-yaml-chunker.ts index d18cd0859f9..9ccac753db1 100644 --- a/apps/sim/lib/chunkers/json-yaml-chunker.ts +++ b/apps/sim/lib/chunkers/json-yaml-chunker.ts @@ -1,7 +1,13 @@ 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, + iterateWordBoundaryChunks, + tokensToChars, +} from '@/lib/chunkers/utils' const logger = createLogger('JsonYamlChunker') @@ -15,10 +21,12 @@ 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.minCharactersPerChunk = options.minCharactersPerChunk ?? 100 + this.maxChunks = options.maxChunks } static isStructuredData(content: string): boolean { @@ -43,25 +51,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 +88,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,7 +123,9 @@ export class JsonYamlChunker { if (itemTokens > this.chunkSize) { if (currentBatch.length > 0) { - chunks.push( + this.addBoundedChunk( + chunks, + budget, this.buildBatchChunk(contextHeader, currentBatch, i - currentBatch.length, i - 1) ) currentBatch = [] @@ -108,16 +133,14 @@ export class JsonYamlChunker { } 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 }, - }) + this.chunkAsText(contextHeader + itemStr, budget, chunks) } } else if (currentTokens + itemTokens > this.chunkSize && currentBatch.length > 0) { - chunks.push( + this.addBoundedChunk( + chunks, + budget, this.buildBatchChunk(contextHeader, currentBatch, i - currentBatch.length, i - 1) ) currentBatch = [item] @@ -129,7 +152,9 @@ export class JsonYamlChunker { } if (currentBatch.length > 0) { - chunks.push( + this.addBoundedChunk( + chunks, + budget, this.buildBatchChunk( contextHeader, currentBatch, @@ -138,12 +163,15 @@ export class JsonYamlChunker { ) ) } - - 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 +180,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 +199,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 +209,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 +233,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 +255,56 @@ export class JsonYamlChunker { } } - private chunkAsText(content: string): Chunk[] { - const chunks: Chunk[] = [] - const lines = content.split('\n') + private addBoundedChunk(chunks: Chunk[], budget: ChunkBudget, chunk: Chunk): void { + if (chunk.tokenCount <= this.chunkSize) { + budget.add(chunks, chunk) + return + } + + let startIndex = chunk.metadata.startIndex + for (const segment of iterateWordBoundaryChunks(chunk.text, tokensToChars(this.chunkSize))) { + budget.add(chunks, { + text: segment, + tokenCount: estimateTokens(segment), + metadata: { startIndex, endIndex: startIndex + segment.length }, + }) + startIndex += segment.length + } + } + + 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 + } + for (const segment of iterateWordBoundaryChunks(line, tokensToChars(this.chunkSize))) { + budget.add(chunks, { + text: segment, + tokenCount: estimateTokens(segment), + metadata: { startIndex, endIndex: startIndex + segment.length }, + }) + startIndex += segment.length + } + startIndex += 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 +320,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 }, diff --git a/apps/sim/lib/chunkers/recursive-chunker.ts b/apps/sim/lib/chunkers/recursive-chunker.ts index c60933787a6..e7f3074fc36 100644 --- a/apps/sim/lib/chunkers/recursive-chunker.ts +++ b/apps/sim/lib/chunkers/recursive-chunker.ts @@ -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 { @@ -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) diff --git a/apps/sim/lib/chunkers/regex-chunker.test.ts b/apps/sim/lib/chunkers/regex-chunker.test.ts index 96d84cc5ed4..17b7e803feb 100644 --- a/apps/sim/lib/chunkers/regex-chunker.test.ts +++ b/apps/sim/lib/chunkers/regex-chunker.test.ts @@ -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 () => { diff --git a/apps/sim/lib/chunkers/regex-chunker.ts b/apps/sim/lib/chunkers/regex-chunker.ts index b294a504eee..131131dbd49 100644 --- a/apps/sim/lib/chunkers/regex-chunker.ts +++ b/apps/sim/lib/chunkers/regex-chunker.ts @@ -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 { + for (const segment of this.regex.iterateSplits(content)) { + if (segment.trim()) yield segment + } + } + + private *prependSegments( + first: string, + second: string, + rest: Iterable + ): Generator { + 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, 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, 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 diff --git a/apps/sim/lib/chunkers/sentence-chunker.ts b/apps/sim/lib/chunkers/sentence-chunker.ts index f8b92e6f22c..2b22a1ec6fb 100644 --- a/apps/sim/lib/chunkers/sentence-chunker.ts +++ b/apps/sim/lib/chunkers/sentence-chunker.ts @@ -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 = + /(? s.trim().length > 0) + private *splitSentences(text: string): Generator { + 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 { @@ -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) diff --git a/apps/sim/lib/chunkers/structured-data-chunker.test.ts b/apps/sim/lib/chunkers/structured-data-chunker.test.ts index 73c9106ae26..cfff1c5acb8 100644 --- a/apps/sim/lib/chunkers/structured-data-chunker.test.ts +++ b/apps/sim/lib/chunkers/structured-data-chunker.test.ts @@ -128,6 +128,43 @@ 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(5_000)}-END` + const row = `ROW-${'r'.repeat(10_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.some((chunk) => chunk.text.includes('HEADER-'))).toBe(true) + expect(chunks.some((chunk) => chunk.text.includes('ROW-'))).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.concurrent('should include token count in chunk metadata', async () => { const csv = `name,age Alice,30 diff --git a/apps/sim/lib/chunkers/structured-data-chunker.ts b/apps/sim/lib/chunkers/structured-data-chunker.ts index 757e8b67fdb..d6d74ca2d99 100644 --- a/apps/sim/lib/chunkers/structured-data-chunker.ts +++ b/apps/sim/lib/chunkers/structured-data-chunker.ts @@ -1,5 +1,7 @@ import { createLogger } from '@sim/logger' +import { ChunkBudget } from '@/lib/chunkers/chunk-budget' import type { Chunk, StructuredDataOptions } from '@/lib/chunkers/types' +import { iterateLines, iterateWordBoundaryChunks } from '@/lib/chunkers/utils' /** Structured data is denser in tokens (~3 chars/token vs ~4 for prose) */ function estimateStructuredTokens(text: string): number { @@ -22,46 +24,86 @@ export class StructuredDataChunker { options: StructuredDataOptions = {} ): Promise { const chunks: Chunk[] = [] - const lines = content.split('\n').filter((line) => line.trim()) + const sampleLines: string[] = [] + for (const line of iterateLines(content)) { + if (!line.trim()) continue + sampleLines.push(line) + if (sampleLines.length === 10) break + } - if (lines.length === 0) { + if (sampleLines.length === 0) { return chunks } + const budget = new ChunkBudget(options.maxChunks) const targetChunkSize = options.chunkSize ?? DEFAULT_CONFIG.TARGET_CHUNK_SIZE - const headerLine = options.headers?.join('\t') || lines[0] + 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 chunkStartRow = dataStartIndex - for (let i = dataStartIndex; i < lines.length; i++) { - const row = lines[i] + let lineIndex = 0 + for (const row of iterateLines(content)) { + if (!row.trim()) continue + const i = lineIndex + lineIndex++ + if (i < dataStartIndex) continue const rowTokens = estimateStructuredTokens(row) + 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 = [] + currentTokenEstimate = 0 + } + const emptyRowOverhead = estimateStructuredTokens( + StructuredDataChunker.formatChunk(headerLine, [''], options.sheetName) + ) + if (emptyRowOverhead >= targetChunkSize) { + for (const segment of iterateWordBoundaryChunks(standaloneRow, targetChunkSize * 3)) { + budget.add(chunks, StructuredDataChunker.createChunk(segment, i, i)) + } + chunkStartRow = i + 1 + continue + } + const rowSegmentChars = Math.max(1, (targetChunkSize - emptyRowOverhead) * 3) + for (const segment of iterateWordBoundaryChunks(row, rowSegmentChars)) { + const segmentContent = StructuredDataChunker.formatChunk( + headerLine, + [segment], + options.sheetName + ) + budget.add(chunks, StructuredDataChunker.createChunk(segmentContent, i, i)) + } + chunkStartRow = i + 1 + continue + } + const projectedTokens = currentTokenEstimate + rowTokens + (DEFAULT_CONFIG.INCLUDE_HEADERS_IN_EACH_CHUNK ? headerTokens : 0) 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,7 +112,7 @@ 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 @@ -87,10 +129,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 +203,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', '|'] diff --git a/apps/sim/lib/chunkers/text-chunker.ts b/apps/sim/lib/chunkers/text-chunker.ts index eb993b609aa..5197951af83 100644 --- a/apps/sim/lib/chunkers/text-chunker.ts +++ b/apps/sim/lib/chunkers/text-chunker.ts @@ -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 { @@ -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) diff --git a/apps/sim/lib/chunkers/token-chunker.ts b/apps/sim/lib/chunkers/token-chunker.ts index d98b4d1651a..5f8e12c7a3e 100644 --- a/apps/sim/lib/chunkers/token-chunker.ts +++ b/apps/sim/lib/chunkers/token-chunker.ts @@ -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 { @@ -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 filtered = - rawChunks.length > 1 - ? rawChunks.filter((c) => c.length >= this.minCharactersPerChunk) - : rawChunks + 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 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) diff --git a/apps/sim/lib/chunkers/types.ts b/apps/sim/lib/chunkers/types.ts index ef38a85b808..04179d852bd 100644 --- a/apps/sim/lib/chunkers/types.ts +++ b/apps/sim/lib/chunkers/types.ts @@ -7,6 +7,7 @@ export interface ChunkerOptions { chunkSize?: number chunkOverlap?: number minCharactersPerChunk?: number + maxChunks?: number } export interface Chunk { diff --git a/apps/sim/lib/chunkers/utils.ts b/apps/sim/lib/chunkers/utils.ts index ded68dbc192..8acc59dc74f 100644 --- a/apps/sim/lib/chunkers/utils.ts +++ b/apps/sim/lib/chunkers/utils.ts @@ -60,7 +60,14 @@ export function splitAtWordBoundaries( chunkSizeChars: number, stepChars?: number ): string[] { - const parts: string[] = [] + return Array.from(iterateWordBoundaryChunks(text, chunkSizeChars, stepChars)) +} + +export function* iterateWordBoundaryChunks( + text: string, + chunkSizeChars: number, + stepChars?: number +): Generator { let pos = 0 while (pos < text.length) { @@ -68,30 +75,65 @@ 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) - } + if (part) yield part 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++ } +} + +/** Iterates literal-separated parts while preserving String.split's raw part values. */ +export function* iterateLiteralParts(text: string, separator: string): Generator { + if (!separator) { + yield text + return + } - return parts + 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 { + 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[] { diff --git a/apps/sim/lib/core/security/linear-regex.differential.test.ts b/apps/sim/lib/core/security/linear-regex.differential.test.ts index 02c5abbf516..2772b019026 100644 --- a/apps/sim/lib/core/security/linear-regex.differential.test.ts +++ b/apps/sim/lib/core/security/linear-regex.differential.test.ts @@ -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 = [ diff --git a/apps/sim/lib/core/security/linear-regex.test.ts b/apps/sim/lib/core/security/linear-regex.test.ts index c6edab05414..25c8c19c227 100644 --- a/apps/sim/lib/core/security/linear-regex.test.ts +++ b/apps/sim/lib/core/security/linear-regex.test.ts @@ -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 = 'onetwothree' expect(compileLookaroundSplit('(?<=)')?.split(doc)).toEqual([ diff --git a/apps/sim/lib/core/security/linear-regex.ts b/apps/sim/lib/core/security/linear-regex.ts index a22134473cb..7c17a693404 100644 --- a/apps/sim/lib/core/security/linear-regex.ts +++ b/apps/sim/lib/core/security/linear-regex.ts @@ -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 } const METACHARACTERS = /[.*+?^${}()|[\]\\]/ @@ -284,28 +285,26 @@ export function compileLookaroundSplit( return { start: matcher.start('mid'), end: matcher.end('mid') } } + const iterateSplits = function* (text: string): Generator { + 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 { + 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 { diff --git a/apps/sim/lib/core/security/redaction.test.ts b/apps/sim/lib/core/security/redaction.test.ts index e5c60abf969..334e4d082c6 100644 --- a/apps/sim/lib/core/security/redaction.test.ts +++ b/apps/sim/lib/core/security/redaction.test.ts @@ -4,6 +4,7 @@ import { isSensitiveKey, REDACTED_MARKER, redactApiKeys, + redactExactSensitiveValues, redactSensitiveValues, sanitizeEventData, sanitizeForLogging, @@ -178,6 +179,110 @@ 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('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('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('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 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) diff --git a/apps/sim/lib/core/security/redaction.ts b/apps/sim/lib/core/security/redaction.ts index 09bfb1890af..303d55a364f 100644 --- a/apps/sim/lib/core/security/redaction.ts +++ b/apps/sim/lib/core/security/redaction.ts @@ -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']) @@ -73,14 +73,93 @@ const SENSITIVE_VALUE_PATTERNS: Array<{ }, ] +const FORM_FIELD_MARKER_PATTERN = /\b([A-Za-z0-9_-]+)=/gi +const ENCODED_FORM_FIELD_MARKER_PATTERN = /\b([A-Za-z0-9_-]+)%3D/gi +const FORM_VALUE_DELIMITER_PATTERN = /&|\s/g +const ENCODED_FORM_VALUE_DELIMITER_PATTERN = /%26|&|\s/gi + +interface SensitiveValueSpan { + start: number + end: number +} + 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 findFormValueEnd(delimiterPositions: number[], start: number): number { + let lower = 0 + let upper = delimiterPositions.length + while (lower < upper) { + const middle = Math.floor((lower + upper) / 2) + if (delimiterPositions[middle] < start) lower = middle + 1 + else upper = middle + } + return delimiterPositions[lower] +} + +function collectSensitiveValueSpans( + value: string, + markerPattern: RegExp, + delimiterPositions: number[] +): SensitiveValueSpan[] { + const spans: SensitiveValueSpan[] = [] + for (const match of value.matchAll(markerPattern)) { + if (match.index === undefined || !isSensitiveKey(match[1])) continue + const start = match.index + match[0].length + const end = findFormValueEnd(delimiterPositions, start) + if (end > start) spans.push({ start, end }) + } + return spans +} + +function collectDelimiterPositions(value: string, pattern: RegExp): number[] { + const delimiterPositions: number[] = [] + for (const match of value.matchAll(pattern)) { + if (match.index !== undefined) delimiterPositions.push(match.index) + } + delimiterPositions.push(value.length) + return delimiterPositions +} + +function redactSensitiveFormFields(value: string): string { + const formDelimiterPositions = collectDelimiterPositions(value, FORM_VALUE_DELIMITER_PATTERN) + const encodedDelimiterPositions = collectDelimiterPositions( + value, + ENCODED_FORM_VALUE_DELIMITER_PATTERN + ) + const spans = [ + ...collectSensitiveValueSpans(value, FORM_FIELD_MARKER_PATTERN, formDelimiterPositions), + ...collectSensitiveValueSpans( + value, + ENCODED_FORM_FIELD_MARKER_PATTERN, + encodedDelimiterPositions + ), + ].sort((left, right) => left.start - right.start || right.end - left.end) + + if (spans.length === 0) return value + + const merged: SensitiveValueSpan[] = [] + for (const span of spans) { + const previous = merged.at(-1) + if (previous && span.start <= previous.end) { + previous.end = Math.max(previous.end, span.end) + } else { + merged.push({ ...span }) + } + } + + let result = '' + let cursor = 0 + for (const span of merged) { + result += `${value.slice(cursor, span.start)}${REDACTED_MARKER}` + cursor = span.end + } + return result + value.slice(cursor) +} + /** * Redacts sensitive patterns from a string value * @param value - The string to redact @@ -91,13 +170,44 @@ 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([ + encodeURIComponent(secret), + new URLSearchParams({ value: secret }).toString().slice('value='.length), + ]) + 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) } diff --git a/apps/sim/lib/core/utils/stream-limits.test.ts b/apps/sim/lib/core/utils/stream-limits.test.ts index 65d789887b1..846ff924aa3 100644 --- a/apps/sim/lib/core/utils/stream-limits.test.ts +++ b/apps/sim/lib/core/utils/stream-limits.test.ts @@ -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')) diff --git a/apps/sim/lib/core/utils/stream-limits.ts b/apps/sim/lib/core/utils/stream-limits.ts index 0c5e75c8715..246983ab906 100644 --- a/apps/sim/lib/core/utils/stream-limits.ts +++ b/apps/sim/lib/core/utils/stream-limits.ts @@ -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 | null arrayBuffer?: () => Promise @@ -306,6 +308,16 @@ export async function readResponseToBufferWithLimit( }, options: ReadResponseWithLimitOptions ): Promise { + 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 | null arrayBuffer?: () => Promise diff --git a/apps/sim/lib/embeddings/client.test.ts b/apps/sim/lib/embeddings/client.test.ts index 4f7d27d9a1e..37286786e26 100644 --- a/apps/sim/lib/embeddings/client.test.ts +++ b/apps/sim/lib/embeddings/client.test.ts @@ -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 rawJsonResponse(body: string, status = 200): Response { + return new Response(body, { + status, + statusText: String(status), + headers: new Headers({ 'content-type': 'application/json' }), + }) } -function openAIBody(vectors: number[][], totalTokens = 5) { +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({ + pull(controller) { + if (emitted >= chunkCount) { + controller.close() + return + } + controller.enqueue(chunk) + emitted++ + }, + }), + { status: 200 } + ) +} + let fetchMock: ReturnType 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 void>() + fetchMock.mockImplementation( + async (_url, init) => + new Promise((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) diff --git a/apps/sim/lib/embeddings/client.ts b/apps/sim/lib/embeddings/client.ts index 28bfe63ccfc..b761f8297af 100644 --- a/apps/sim/lib/embeddings/client.ts +++ b/apps/sim/lib/embeddings/client.ts @@ -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 { + 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 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 { + 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, - 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,27 +783,60 @@ 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 result = combineEmbeddingBatches(batchResults) + 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 + ) - if (result.embeddings.length !== boundedInputs.length) { - throw new Error( - `OpenRouter returned ${result.embeddings.length} embeddings for ${boundedInputs.length} inputs` + 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 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 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) + + const dimensions = result.dimensions + if (dimensions === undefined) { + throw new EmbeddingResponseValidationError('the response did not contain any vectors') + } return { embeddings: result.embeddings, @@ -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 } } diff --git a/apps/sim/lib/embeddings/index.ts b/apps/sim/lib/embeddings/index.ts index 48ce4e1711d..3e022df6bb2 100644 --- a/apps/sim/lib/embeddings/index.ts +++ b/apps/sim/lib/embeddings/index.ts @@ -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, diff --git a/apps/sim/lib/embeddings/quota-circuit.test.ts b/apps/sim/lib/embeddings/quota-circuit.test.ts new file mode 100644 index 00000000000..b388c1136e7 --- /dev/null +++ b/apps/sim/lib/embeddings/quota-circuit.test.ts @@ -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() + 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) + }) +}) diff --git a/apps/sim/lib/embeddings/quota-circuit.ts b/apps/sim/lib/embeddings/quota-circuit.ts new file mode 100644 index 00000000000..f595b780372 --- /dev/null +++ b/apps/sim/lib/embeddings/quota-circuit.ts @@ -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() + +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 { + 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 { + 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() +} diff --git a/apps/sim/lib/file-parsers/csv-parser.ts b/apps/sim/lib/file-parsers/csv-parser.ts index 81f9331f64a..6d4c6c9ec23 100644 --- a/apps/sim/lib/file-parsers/csv-parser.ts +++ b/apps/sim/lib/file-parsers/csv-parser.ts @@ -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', () => { diff --git a/apps/sim/lib/file-parsers/data-uri.test.ts b/apps/sim/lib/file-parsers/data-uri.test.ts new file mode 100644 index 00000000000..5fc6f2f50ec --- /dev/null +++ b/apps/sim/lib/file-parsers/data-uri.test.ts @@ -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' })) + }) +}) diff --git a/apps/sim/lib/file-parsers/data-uri.ts b/apps/sim/lib/file-parsers/data-uri.ts new file mode 100644 index 00000000000..716c37538b5 --- /dev/null +++ b/apps/sim/lib/file-parsers/data-uri.ts @@ -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 } +} diff --git a/apps/sim/lib/file-parsers/doc-parser.test.ts b/apps/sim/lib/file-parsers/doc-parser.test.ts index cf65c95cda0..e4865fcbea0 100644 --- a/apps/sim/lib/file-parsers/doc-parser.test.ts +++ b/apps/sim/lib/file-parsers/doc-parser.test.ts @@ -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 () => { diff --git a/apps/sim/lib/file-parsers/doc-parser.ts b/apps/sim/lib/file-parsers/doc-parser.ts index 6e6cc1c773e..c15424b30ee 100644 --- a/apps/sim/lib/file-parsers/doc-parser.ts +++ b/apps/sim/lib/file-parsers/doc-parser.ts @@ -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 { - try { - if (!filePath) { - throw new Error('No file path provided') - } - - if (!existsSync(filePath)) { - throw new Error(`File not found: ${filePath}`) - } + if (!filePath) { + throw new Error('No file path provided') + } - 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 (!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 { 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 } } diff --git a/apps/sim/lib/file-parsers/docx-parser.ts b/apps/sim/lib/file-parsers/docx-parser.ts index c2441a2cb4b..55a3a869c54 100644 --- a/apps/sim/lib/file-parsers/docx-parser.ts +++ b/apps/sim/lib/file-parsers/docx-parser.ts @@ -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 { - 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 { 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') } } } diff --git a/apps/sim/lib/file-parsers/errors.test.ts b/apps/sim/lib/file-parsers/errors.test.ts new file mode 100644 index 00000000000..66d221b40ab --- /dev/null +++ b/apps/sim/lib/file-parsers/errors.test.ts @@ -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) + }) +}) diff --git a/apps/sim/lib/file-parsers/errors.ts b/apps/sim/lib/file-parsers/errors.ts new file mode 100644 index 00000000000..0f3cbff5d27 --- /dev/null +++ b/apps/sim/lib/file-parsers/errors.ts @@ -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) + ) +} diff --git a/apps/sim/lib/file-parsers/html-parser.test.ts b/apps/sim/lib/file-parsers/html-parser.test.ts index 464107cc037..17013dfd1aa 100644 --- a/apps/sim/lib/file-parsers/html-parser.test.ts +++ b/apps/sim/lib/file-parsers/html-parser.test.ts @@ -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 diff --git a/apps/sim/lib/file-parsers/html-parser.ts b/apps/sim/lib/file-parsers/html-parser.ts index c1532dcfbbc..d7fd0e396d3 100644 --- a/apps/sim/lib/file-parsers/html-parser.ts +++ b/apps/sim/lib/file-parsers/html-parser.ts @@ -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 { + 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 + ) } } diff --git a/apps/sim/lib/file-parsers/index.ts b/apps/sim/lib/file-parsers/index.ts index 4c50c53e4f8..9ae9e855fab 100644 --- a/apps/sim/lib/file-parsers/index.ts +++ b/apps/sim/lib/file-parsers/index.ts @@ -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 { export async function parseBuffer(buffer: Buffer, extension: string): Promise { 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 { metadata, } } catch (error) { - throw new Error(`Invalid JSON: ${getErrorMessage(error, 'Unknown 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 + ) } } @@ -56,7 +67,15 @@ export async function parseJSONBuffer(buffer: Buffer): Promise metadata, } } catch (error) { - throw new Error(`Invalid JSON: ${getErrorMessage(error, 'Unknown 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 + ) } } @@ -84,8 +103,12 @@ function parseJSONLContent(content: string): FileParseResult { 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)}`) + } catch (error) { + throw new FileParserError( + 'invalid_format', + `Invalid JSONL: failed to parse line: ${line.slice(0, 100)}`, + error + ) } } @@ -106,13 +129,19 @@ function parseJSONLContent(content: string): FileParseResult { /** * Calculate the depth of a JSON object */ -function getJsonDepth(obj: any): number { - if (obj === null || typeof obj !== 'object') return 0 - - if (Array.isArray(obj)) { - return obj.length > 0 ? 1 + Math.max(...obj.map(getJsonDepth)) : 1 +function getJsonDepth(value: unknown, depth = 0): number { + if (value === null || typeof value !== 'object') return depth + if (depth >= MAX_JSON_DEPTH) { + throw new FileParserError( + 'complexity_limit', + `JSON document exceeds the maximum nesting depth of ${MAX_JSON_DEPTH}` + ) } - const depths = Object.values(obj).map(getJsonDepth) - return depths.length > 0 ? 1 + Math.max(...depths) : 1 + let maxDepth = depth + 1 + const children = Array.isArray(value) ? value : Object.values(value as Record) + for (const child of children) { + maxDepth = Math.max(maxDepth, getJsonDepth(child, depth + 1)) + } + return maxDepth } diff --git a/apps/sim/lib/file-parsers/officeparser-module.ts b/apps/sim/lib/file-parsers/officeparser-module.ts index bbd86b791df..cf1ceb078f7 100644 --- a/apps/sim/lib/file-parsers/officeparser-module.ts +++ b/apps/sim/lib/file-parsers/officeparser-module.ts @@ -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 @@ -46,5 +48,13 @@ export function resolveParseOfficeAsync(mod: OfficeParserModule): ParseOfficeAsy * can only assert the shape that already worked. */ export async function loadParseOfficeAsync(): Promise { - 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 + ) + } } diff --git a/apps/sim/lib/file-parsers/ooxml-limits.ts b/apps/sim/lib/file-parsers/ooxml-limits.ts index 8d4c4d71612..772ce56aba3 100644 --- a/apps/sim/lib/file-parsers/ooxml-limits.ts +++ b/apps/sim/lib/file-parsers/ooxml-limits.ts @@ -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' + } +} diff --git a/apps/sim/lib/file-parsers/opendocument-parser.ts b/apps/sim/lib/file-parsers/opendocument-parser.ts index 936b8476dbf..a97321ca9a0 100644 --- a/apps/sim/lib/file-parsers/opendocument-parser.ts +++ b/apps/sim/lib/file-parsers/opendocument-parser.ts @@ -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 { 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 { diff --git a/apps/sim/lib/file-parsers/parser-formats.test.ts b/apps/sim/lib/file-parsers/parser-formats.test.ts index 47a00356d13..fedaec26a1a 100644 --- a/apps/sim/lib/file-parsers/parser-formats.test.ts +++ b/apps/sim/lib/file-parsers/parser-formats.test.ts @@ -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('') + + 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 diff --git a/apps/sim/lib/file-parsers/pptx-parser.test.ts b/apps/sim/lib/file-parsers/pptx-parser.test.ts new file mode 100644 index 00000000000..c7cda6e2ba5 --- /dev/null +++ b/apps/sim/lib/file-parsers/pptx-parser.test.ts @@ -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({ + code: 'encrypted_file', + cause: libraryError, + }) + }) +}) diff --git a/apps/sim/lib/file-parsers/pptx-parser.ts b/apps/sim/lib/file-parsers/pptx-parser.ts index b9a0efb575e..10db14bd6b1 100644 --- a/apps/sim/lib/file-parsers/pptx-parser.ts +++ b/apps/sim/lib/file-parsers/pptx-parser.ts @@ -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 { - try { - if (!filePath) { - throw new Error('No file path provided') - } + if (!filePath) { + throw new Error('No file path provided') + } - if (!existsSync(filePath)) { - throw new Error(`File not found: ${filePath}`) - } + if (!existsSync(filePath)) { + throw new Error(`File not found: ${filePath}`) + } - logger.info(`Parsing PowerPoint file: ${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}`) - } + const buffer = await readFile(filePath) + return this.parseBuffer(buffer) } async parseBuffer(buffer: Buffer): Promise { - try { - logger.info('Parsing PowerPoint buffer, size:', buffer.length) + logger.info('Parsing PowerPoint buffer, size:', buffer.length) - if (!buffer || buffer.length === 0) { - throw new Error('Empty buffer provided') - } + if (!buffer || buffer.length === 0) { + throw new FileParserError('empty_input', 'Empty buffer provided') + } - assertOoxmlArchiveWithinLimits(buffer) + assertOoxmlArchiveWithinLimits(buffer) - let parseOfficeAsync - try { - parseOfficeAsync = await loadParseOfficeAsync() - } catch (importError) { - logger.warn('officeparser not available, using fallback extraction') + const parseOfficeAsync = await loadParseOfficeAsync() + + try { + const result = await parseOfficeAsync(buffer) + + if (!result || typeof result !== 'string') { return this.fallbackExtraction(buffer) } - try { - const result = await parseOfficeAsync(buffer) - - if (!result || typeof result !== 'string') { - throw new Error('officeparser returned invalid result') - } + const content = sanitizeTextForUTF8(result.trim()) - const content = sanitizeTextForUTF8(result.trim()) + logger.info('PowerPoint parsing completed successfully with officeparser') - logger.info('PowerPoint parsing completed successfully with officeparser') + 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 + ) + } - 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 + ) } } diff --git a/apps/sim/lib/file-parsers/xlsx-parser.ts b/apps/sim/lib/file-parsers/xlsx-parser.ts index c068fb47694..0095e6df0e6 100644 --- a/apps/sim/lib/file-parsers/xlsx-parser.ts +++ b/apps/sim/lib/file-parsers/xlsx-parser.ts @@ -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 { - try { - if (!filePath) { - throw new Error('No file path provided') - } + if (!filePath) { + throw new Error('No file path provided') + } - if (!existsSync(filePath)) { - throw new Error(`File not found: ${filePath}`) - } + if (!existsSync(filePath)) { + throw new Error(`File not found: ${filePath}`) + } - logger.info(`Parsing XLSX file: ${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}`) - } + const buffer = await readFile(filePath) + return this.parseBuffer(buffer) } async parseBuffer(buffer: Buffer): Promise { @@ -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(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,52 +212,27 @@ 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 + if (!appendContent(`${headerRow}\n${'-'.repeat(Math.min(80, headerRow.length))}\n`)) { + break + } } - // Process data rows in chunks - let chunkContent = '' - let chunkRowCount = 0 - for (let i = 1; i < rowsToProcess; i++) { - const row = sheetData[i] as any[] + const row = sheetData[i] 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(`${rowString}\n`)) break } } - // Add remaining chunk content - if (chunkContent && contentSize < CONFIG.MAX_CONTENT_SIZE) { - content += chunkContent - contentSize += chunkContent.length - } + if (outputLimitReached) break /** * Truncated means the WINDOW cut the sheet short, which is a question @@ -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) } diff --git a/apps/sim/lib/file-parsers/xlsx-preview-bound.test.ts b/apps/sim/lib/file-parsers/xlsx-preview-bound.test.ts index 2e3d825021a..297a6103fb1 100644 --- a/apps/sim/lib/file-parsers/xlsx-preview-bound.test.ts +++ b/apps/sim/lib/file-parsers/xlsx-preview-bound.test.ts @@ -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') + }) }) diff --git a/apps/sim/lib/file-parsers/yaml-parser.test.ts b/apps/sim/lib/file-parsers/yaml-parser.test.ts index 68464ece162..08e24623620 100644 --- a/apps/sim/lib/file-parsers/yaml-parser.test.ts +++ b/apps/sim/lib/file-parsers/yaml-parser.test.ts @@ -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') diff --git a/apps/sim/lib/file-parsers/yaml-parser.ts b/apps/sim/lib/file-parsers/yaml-parser.ts index a0bd297880e..339f5a86853 100644 --- a/apps/sim/lib/file-parsers/yaml-parser.ts +++ b/apps/sim/lib/file-parsers/yaml-parser.ts @@ -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 { 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 { * Parse YAML from buffer */ export async function parseYAMLBuffer(buffer: Buffer): Promise { + 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 + ) } } diff --git a/apps/sim/lib/file-parsers/zip-guard.test.ts b/apps/sim/lib/file-parsers/zip-guard.test.ts index 6ed2d923abb..113686e32a4 100644 --- a/apps/sim/lib/file-parsers/zip-guard.test.ts +++ b/apps/sim/lib/file-parsers/zip-guard.test.ts @@ -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/ ) diff --git a/apps/sim/lib/file-parsers/zip-guard.ts b/apps/sim/lib/file-parsers/zip-guard.ts index 1ba027c8cd6..096d0308a9c 100644 --- a/apps/sim/lib/file-parsers/zip-guard.ts +++ b/apps/sim/lib/file-parsers/zip-guard.ts @@ -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}`) } } diff --git a/apps/sim/lib/knowledge/application/documents.ts b/apps/sim/lib/knowledge/application/documents.ts index 08db2444b6c..1cefe262fe2 100644 --- a/apps/sim/lib/knowledge/application/documents.ts +++ b/apps/sim/lib/knowledge/application/documents.ts @@ -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, diff --git a/apps/sim/lib/knowledge/connectors/sync-engine.test.ts b/apps/sim/lib/knowledge/connectors/sync-engine.test.ts index 1d9cbc63ff1..23e5d5fa564 100644 --- a/apps/sim/lib/knowledge/connectors/sync-engine.test.ts +++ b/apps/sim/lib/knowledge/connectors/sync-engine.test.ts @@ -20,17 +20,32 @@ import { isConnectorRunnableStatus, isStuckDocumentSweepEligible, mergeHydratedDocument, + mergeHydratedSkippedDocument, type PreviousListingObservation, + selectStuckDocumentSweepCandidates, + stuckDocumentSweepAgeAnchor, } from '@/lib/knowledge/connectors/sync-engine' -import type { ExternalDocument } from '@/connectors/types' +import type { ExternalDocument, SyncResult } from '@/connectors/types' vi.mock('drizzle-orm', () => drizzleOrmMock) +const { mockProcessDocumentsWithQueue, mockUploadFile } = vi.hoisted(() => ({ + mockProcessDocumentsWithQueue: vi.fn(), + mockUploadFile: vi.fn(), +})) + vi.mock('@/lib/knowledge/documents/service', () => ({ hardDeleteDocuments: vi.fn(), isTriggerAvailable: vi.fn(), processDocumentAsync: vi.fn(), + processDocumentsWithQueue: mockProcessDocumentsWithQueue, })) -vi.mock('@/lib/uploads', () => ({ StorageService: {} })) +vi.mock('@/lib/uploads', () => ({ StorageService: { uploadFile: mockUploadFile } })) +const { mockDeleteFile, mockDeleteFileMetadata } = vi.hoisted(() => ({ + mockDeleteFile: vi.fn(), + mockDeleteFileMetadata: vi.fn(), +})) +vi.mock('@/lib/uploads/core/storage-service', () => ({ deleteFile: mockDeleteFile })) +vi.mock('@/lib/uploads/server/metadata', () => ({ deleteFileMetadata: mockDeleteFileMetadata })) vi.mock('@/lib/oauth/credential-service', () => authOAuthUtilsMock) vi.mock('@/background/knowledge-connector-sync', () => ({ knowledgeConnectorSync: { trigger: vi.fn() }, @@ -109,6 +124,13 @@ describe('shouldReconcileDeletions', () => { shouldReconcileDeletions(false, { listingCapped: true, listingTruncated: true }, true) ).toBe(false) }) + + it('never runs when provider pagination is non-authoritative', async () => { + const { shouldReconcileDeletions } = await import('@/lib/knowledge/connectors/sync-engine') + + expect(shouldReconcileDeletions(false, { reconciliationUnsafe: true }, undefined)).toBe(false) + expect(shouldReconcileDeletions(false, { reconciliationUnsafe: true }, true)).toBe(false) + }) }) describe('shouldRunIncrementalSync', () => { @@ -465,11 +487,50 @@ describe('classifyExternalDoc', () => { { id: 'doc-1', contentHash: 'old', + storageKey: 'kb/indexed-file.txt', } ) ).toEqual({ type: 'unchanged' }) }) + it('refreshes an existing skipped placeholder without turning it into a source failure', async () => { + const { classifyExternalDoc } = await import('@/lib/knowledge/connectors/sync-engine') + + expect( + classifyExternalDoc( + { ...base, content: '', skippedReason: 'too big' }, + { id: 'doc-1', contentHash: 'old', storageKey: null } + ) + ).toEqual({ type: 'skip', existingId: 'doc-1' }) + }) + + it('rehydrates a content-less placeholder even when its listing hash is unchanged', async () => { + const { classifyExternalDoc } = await import('@/lib/knowledge/connectors/sync-engine') + + expect( + classifyExternalDoc( + { ...base, content: '', contentDeferred: true }, + { id: 'doc-1', contentHash: 'h1', storageKey: null } + ) + ).toEqual({ type: 'update', existingId: 'doc-1' }) + }) + + it('replaces stale indexed content for an authoritative skip', async () => { + const { classifyExternalDoc } = await import('@/lib/knowledge/connectors/sync-engine') + + expect( + classifyExternalDoc( + { + ...base, + content: '', + skippedReason: 'no extractable text', + skippedExistingDisposition: 'replace', + }, + { id: 'doc-1', contentHash: 'old' } + ) + ).toEqual({ type: 'skip', existingId: 'doc-1' }) + }) + it('drops empty non-deferred content', async () => { const { classifyExternalDoc } = await import('@/lib/knowledge/connectors/sync-engine') expect(classifyExternalDoc({ ...base, content: ' ' }, undefined)).toEqual({ type: 'drop' }) @@ -513,6 +574,284 @@ describe('classifyExternalDoc', () => { }) }) +describe('connector content replacement processing state', () => { + const CONNECTOR = { + id: 'connector-1', + knowledgeBaseId: 'kb-1', + connectorType: 'paged', + credentialId: null, + encryptedApiKey: null, + sourceConfig: {}, + syncMode: 'full', + syncIntervalMinutes: 1440, + status: 'active', + lastSyncAt: null, + lastSyncDocCount: 1, + consecutiveFailures: 0, + syncLockToken: null, + } + + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mockUploadFile.mockResolvedValue({ + key: 'kb/new-document.txt', + path: '/api/files/serve/kb/new-document.txt', + }) + mockProcessDocumentsWithQueue.mockResolvedValue({ requested: 1, accepted: 1, failed: 0 }) + }) + + it('resets a near-dead-letter prior version when authoritative content changes', async () => { + const { executeSync } = await import('@/lib/knowledge/connectors/sync-engine') + const { MAX_PROCESSING_ATTEMPTS } = await import('@/lib/knowledge/documents/types') + + queueTableRows(schemaMock.knowledgeConnector, [CONNECTOR]) + for (let i = 0; i < 20; i++) { + queueTableRows(schemaMock.knowledgeConnector, [ + { + connectorArchivedAt: null, + connectorDeletedAt: null, + kbDeletedAt: null, + }, + ]) + } + for (let i = 0; i < 10; i++) { + queueTableRows(schemaMock.knowledgeBase, [{ id: 'kb-1', userId: 'u-1', workspaceId: 'ws-1' }]) + } + queueTableRows(schemaMock.document, []) + queueTableRows(schemaMock.document, [ + { + id: 'doc-1', + externalId: 'external-1', + contentHash: 'old-hash', + deletedAt: null, + userExcluded: false, + processingAttempts: MAX_PROCESSING_ATTEMPTS - 1, + }, + ]) + queueTableRows(schemaMock.document, []) + queueTableRows(schemaMock.document, []) + queueTableRows(schemaMock.document, [ + { fileUrl: '/api/files/serve/kb/old-document.txt?context=knowledge-base' }, + ]) + queueTableRows(schemaMock.document, []) + queueTableRows(schemaMock.document, [{ count: 1 }]) + dbChainMockFns.returning + .mockResolvedValueOnce([CONNECTOR]) + .mockResolvedValueOnce([{ id: 'doc-1' }]) + + mockListDocuments.mockResolvedValue({ + documents: [ + { + externalId: 'external-1', + title: 'Updated document', + content: 'authoritative new content', + contentHash: 'new-hash', + mimeType: 'text/plain', + metadata: {}, + }, + ], + hasMore: false, + }) + + await executeSync('connector-1', { + billingAttribution: { workspaceId: 'ws-1' } as never, + fullSync: true, + }) + + expect(dbChainMockFns.set).toHaveBeenCalledWith( + expect.objectContaining({ + processingStatus: 'pending', + processingQueuedAt: null, + processingQueueToken: null, + processingDeferredUntil: null, + processingAttempts: 0, + }) + ) + }) +}) + +describe('persistSkippedDocuments', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + + it('persists a new skipped document without dispatching processing', async () => { + const { persistSkippedDocuments } = await import('@/lib/knowledge/connectors/sync-engine') + queueTableRows(schemaMock.knowledgeBase, [{ id: 'kb-1' }]) + + await expect( + persistSkippedDocuments('kb-1', 'connector-1', 'no-tags', [ + { + type: 'skip', + extDoc: { + externalId: 'external-1', + title: 'Empty document', + content: '', + mimeType: 'text/plain', + contentHash: 'empty-hash', + skippedReason: 'Document contains no extractable text', + skippedExistingDisposition: 'replace', + }, + }, + ]) + ).resolves.toBe(1) + + expect(dbChainMockFns.values).toHaveBeenCalledWith([ + expect.objectContaining({ + connectorId: 'connector-1', + externalId: 'external-1', + storageKey: null, + processingStatus: 'failed', + contentHash: 'empty-hash', + }), + ]) + expect(dbChainMockFns.delete).not.toHaveBeenCalled() + }) + + it('atomically replaces stale indexed content for an authoritative skip', async () => { + const { persistSkippedDocuments } = await import('@/lib/knowledge/connectors/sync-engine') + const oldFileUrl = '/api/files/serve/kb/old-document.txt?context=knowledge-base' + queueTableRows(schemaMock.knowledgeBase, [{ id: 'kb-1' }]) + queueTableRows(schemaMock.document, [{ fileUrl: oldFileUrl }]) + dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'doc-1' }]) + + await expect( + persistSkippedDocuments('kb-1', 'connector-1', 'no-tags', [ + { + type: 'skip', + existingId: 'doc-1', + extDoc: { + externalId: 'external-1', + title: 'Empty document', + content: '', + mimeType: 'text/plain', + contentHash: 'new-empty-hash', + skippedReason: 'Document contains no extractable text', + skippedExistingDisposition: 'replace', + }, + }, + ]) + ).resolves.toBe(1) + + expect(dbChainMockFns.set).toHaveBeenCalledWith( + expect.objectContaining({ + fileUrl: '', + storageKey: null, + processingStatus: 'failed', + processingError: 'Document contains no extractable text', + processingQueuedAt: null, + processingQueueToken: null, + processingDeferredUntil: null, + processingAttempts: 0, + chunkCount: 0, + contentHash: 'new-empty-hash', + deletedAt: null, + }) + ) + expect(dbChainMockFns.delete).toHaveBeenCalledWith(schemaMock.embedding) + expect(mockDeleteFile).toHaveBeenCalledWith({ + key: 'kb/old-document.txt', + context: 'knowledge-base', + }) + expect(mockDeleteFileMetadata).toHaveBeenCalledWith('kb/old-document.txt') + }) + + it('does not delete old storage when the authoritative replacement fails', async () => { + const { persistSkippedDocuments } = await import('@/lib/knowledge/connectors/sync-engine') + queueTableRows(schemaMock.knowledgeBase, [{ id: 'kb-1' }]) + queueTableRows(schemaMock.document, []) + + await expect( + persistSkippedDocuments('kb-1', 'connector-1', 'no-tags', [ + { + type: 'skip', + existingId: 'missing-doc', + extDoc: { + externalId: 'external-1', + title: 'Empty document', + content: '', + mimeType: 'text/plain', + contentHash: 'new-empty-hash', + skippedReason: 'Document contains no extractable text', + skippedExistingDisposition: 'replace', + }, + }, + ]) + ).rejects.toThrow('Document missing-doc is no longer active') + + expect(mockDeleteFile).not.toHaveBeenCalled() + expect(mockDeleteFileMetadata).not.toHaveBeenCalled() + }) +}) + +describe('persistSkippedRetryHashes', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + + it('updates only the retry hash for a last-known-good connector document', async () => { + const { classifyExternalDoc, persistSkippedRetryHashes } = await import( + '@/lib/knowledge/connectors/sync-engine' + ) + queueTableRows(schemaMock.knowledgeBase, [{ id: 'kb-1' }]) + dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'doc-1' }]) + + await expect( + persistSkippedRetryHashes('kb-1', 'connector-1', [ + { + existingId: 'doc-1', + externalId: 'page-1', + contentHash: 'notion:retry:v1:page-1', + }, + ]) + ).resolves.toEqual([]) + + expect(dbChainMockFns.set).toHaveBeenCalledOnce() + expect(dbChainMockFns.set).toHaveBeenCalledWith({ + contentHash: 'notion:retry:v1:page-1', + }) + expect(dbChainMockFns.delete).not.toHaveBeenCalled() + expect( + classifyExternalDoc( + { + content: '', + contentDeferred: true, + contentHash: 'notion:v3:page-1:unchanged', + }, + { id: 'doc-1', contentHash: 'notion:retry:v1:page-1' } + ) + ).toEqual({ type: 'update', existingId: 'doc-1' }) + }) + + it('commits live retry hashes when another document is no longer a connector target', async () => { + const { persistSkippedRetryHashes } = await import('@/lib/knowledge/connectors/sync-engine') + queueTableRows(schemaMock.knowledgeBase, [{ id: 'kb-1' }]) + dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'live-doc' }]).mockResolvedValueOnce([]) + + await expect( + persistSkippedRetryHashes('kb-1', 'connector-1', [ + { + existingId: 'live-doc', + externalId: 'live-page', + contentHash: 'notion:retry:v1:live-page', + }, + { + existingId: 'detached-doc', + externalId: 'detached-page', + contentHash: 'notion:retry:v1:detached-page', + }, + ]) + ).resolves.toEqual(['detached-page']) + + expect(dbChainMockFns.set).toHaveBeenCalledWith({ + contentHash: 'notion:retry:v1:live-page', + }) + }) +}) + describe('chunkOpsByByteBudget', () => { const MB = 1024 * 1024 const addOp = (sizeBytes?: number) => ({ @@ -520,7 +859,8 @@ describe('chunkOpsByByteBudget', () => { extDoc: { externalId: `e-${generateShortId()}`, title: 'f', - content: 'x', + content: sizeBytes == null ? 'x' : '', + contentDeferred: sizeBytes != null, contentHash: 'h', mimeType: 'text/plain', ...(sizeBytes != null ? { metadata: { fileSize: sizeBytes } } : {}), @@ -573,6 +913,242 @@ describe('chunkOpsByByteBudget', () => { }) }) +describe('connector sync working-set bounds', () => { + it('reserves one sentinel row beyond the remaining corpus budget', async () => { + const { + CONNECTOR_SYNC_MAX_CORPUS_DOCUMENTS, + sourcePageFitsSyncWorkingSet, + syncWorkingSetQueryLimit, + } = await import('@/lib/knowledge/connectors/sync-engine') + + expect(syncWorkingSetQueryLimit(0)).toBe(CONNECTOR_SYNC_MAX_CORPUS_DOCUMENTS + 1) + expect(syncWorkingSetQueryLimit(CONNECTOR_SYNC_MAX_CORPUS_DOCUMENTS - 25)).toBe(26) + expect(syncWorkingSetQueryLimit(CONNECTOR_SYNC_MAX_CORPUS_DOCUMENTS)).toBe(1) + expect(sourcePageFitsSyncWorkingSet(CONNECTOR_SYNC_MAX_CORPUS_DOCUMENTS - 1, 1)).toBe(true) + expect(sourcePageFitsSyncWorkingSet(CONNECTOR_SYNC_MAX_CORPUS_DOCUMENTS, 1)).toBe(false) + }) + + it('counts retained source payload in UTF-8 bytes', async () => { + const { addSourcePagePayloadBytes, CONNECTOR_SYNC_MAX_SOURCE_PAYLOAD_BYTES } = await import( + '@/lib/knowledge/connectors/sync-engine' + ) + const document = { + externalId: '', + title: '', + content: 'é', + mimeType: 'text/plain', + metadata: {}, + } + + expect(addSourcePagePayloadBytes(CONNECTOR_SYNC_MAX_SOURCE_PAYLOAD_BYTES - 4, [document])).toBe( + CONNECTOR_SYNC_MAX_SOURCE_PAYLOAD_BYTES + ) + expect(() => + addSourcePagePayloadBytes(CONNECTOR_SYNC_MAX_SOURCE_PAYLOAD_BYTES - 3, [document]) + ).toThrow('retained-payload limit') + }) +}) + +describe('executeSync working-set overflow admission', () => { + const CONNECTOR = { + id: 'c-1', + knowledgeBaseId: 'kb-1', + connectorType: 'paged', + credentialId: null, + encryptedApiKey: null, + sourceConfig: {}, + syncMode: 'full', + syncIntervalMinutes: 1440, + status: 'active', + lastSyncAt: null, + lastSyncDocCount: null, + consecutiveFailures: 0, + syncLockToken: null, + } + + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + queueTableRows(schemaMock.knowledgeConnector, [CONNECTOR]) + queueTableRows(schemaMock.knowledgeBase, [{ userId: 'u-1', workspaceId: 'ws-1' }]) + queueTableRows(schemaMock.document, []) + dbChainMockFns.returning.mockResolvedValueOnce([CONNECTOR]) + }) + + function trackedSourceDocument(externalId: string) { + let contentReads = 0 + const document: ExternalDocument = { + externalId, + title: externalId, + get content() { + contentReads++ + return 'body' + }, + contentHash: 'hash', + mimeType: 'text/plain', + metadata: {}, + } + return { document, contentReads: () => contentReads } + } + + function expectLockGuardedTerminalFailure(result: SyncResult): void { + expect(result).toMatchObject({ + docsAdded: 0, + docsUpdated: 0, + docsDeleted: 0, + docsUnchanged: 0, + docsSkipped: 0, + docsFailed: 0, + processingDispatch: { requested: 0, accepted: 0, failed: 0 }, + error: expect.stringContaining('exceeds the safe per-corpus limit'), + }) + + const startedLog = dbChainMockFns.values.mock.calls.find( + ([values]) => + (values as Record).connectorId === 'c-1' && + (values as Record).status === 'started' + )?.[0] as Record | undefined + expect(startedLog?.id).toEqual(expect.any(String)) + + const guardedTerminalWhere = dbChainMockFns.where.mock.calls.find(([condition]) => + hasMockCondition( + condition, + (node: MockCondition) => + node.type === 'eq' && + node.left === schemaMock.knowledgeConnector.syncLockToken && + node.right === startedLog?.id + ) + )?.[0] + expect(guardedTerminalWhere).toBeDefined() + expect(dbChainMockFns.set).toHaveBeenCalledWith( + expect.objectContaining({ status: 'error', syncLockToken: null, syncLockLeaseAt: null }) + ) + expect( + hasMockCondition( + guardedTerminalWhere, + (node: MockCondition) => + node.type === 'eq' && + node.left === schemaMock.knowledgeConnector.status && + node.right === 'syncing' + ) + ).toBe(true) + expect( + hasMockCondition( + guardedTerminalWhere, + (node: MockCondition) => + node.type === 'eq' && + node.left === schemaMock.knowledgeConnector.id && + node.right === 'c-1' + ) + ).toBe(true) + } + + async function expectNoDocumentWork(): Promise { + const { hardDeleteDocuments } = await import('@/lib/knowledge/documents/service') + + expect(dbChainMockFns.insert).not.toHaveBeenCalledWith(schemaMock.document) + expect(dbChainMockFns.update).not.toHaveBeenCalledWith(schemaMock.document) + expect(dbChainMockFns.delete).not.toHaveBeenCalledWith(schemaMock.document) + expect(dbChainMockFns.transaction).not.toHaveBeenCalled() + expect(hardDeleteDocuments).not.toHaveBeenCalled() + expect(mockProcessDocumentsWithQueue).not.toHaveBeenCalled() + } + + it('rejects overflow on a later source page before classification or document work', async () => { + const { CONNECTOR_SYNC_MAX_CORPUS_DOCUMENTS, executeSync } = await import( + '@/lib/knowledge/connectors/sync-engine' + ) + const retained = trackedSourceDocument('retained') + const overflow = trackedSourceDocument('overflow') + mockListDocuments + .mockResolvedValueOnce({ + documents: Array(CONNECTOR_SYNC_MAX_CORPUS_DOCUMENTS).fill(retained.document), + hasMore: true, + nextCursor: 'page-2', + }) + .mockResolvedValueOnce({ documents: [overflow.document], hasMore: false }) + + const result = await executeSync('c-1', { + billingAttribution: { workspaceId: 'ws-1' } as never, + }) + + expect(mockListDocuments).toHaveBeenCalledTimes(2) + expect(retained.contentReads()).toBe(CONNECTOR_SYNC_MAX_CORPUS_DOCUMENTS) + expect(overflow.contentReads()).toBe(0) + expectLockGuardedTerminalFailure(result) + await expectNoDocumentWork() + }) + + it.each([ + { + population: 'active', + expectedDocumentReads: 2, + populations: (limit: number) => [ + Array(limit + 1).fill({ + id: 'active', + externalId: 'active', + contentHash: 'hash', + userExcluded: false, + }), + ], + }, + { + population: 'tombstoned', + expectedDocumentReads: 3, + populations: (limit: number) => [ + [{ id: 'active', externalId: 'active', contentHash: 'hash', userExcluded: false }], + Array(limit).fill({ + id: 'tombstoned', + externalId: 'tombstoned', + contentHash: 'hash', + deletedAt: new Date(), + userExcluded: false, + }), + ], + }, + { + population: 'excluded', + expectedDocumentReads: 4, + populations: (limit: number) => [ + [{ id: 'active', externalId: 'active', contentHash: 'hash', userExcluded: false }], + [ + { + id: 'tombstoned', + externalId: 'tombstoned', + contentHash: 'hash', + deletedAt: new Date(), + userExcluded: false, + }, + ], + Array(limit - 1).fill({ externalId: 'excluded' }), + ], + }, + ])( + 'rejects overflow in the sequential $population population before classification or document work', + async ({ expectedDocumentReads, populations }) => { + const { CONNECTOR_SYNC_MAX_CORPUS_DOCUMENTS, executeSync } = await import( + '@/lib/knowledge/connectors/sync-engine' + ) + const listed = trackedSourceDocument('new-source-document') + mockListDocuments.mockResolvedValue({ documents: [listed.document], hasMore: false }) + for (const population of populations(CONNECTOR_SYNC_MAX_CORPUS_DOCUMENTS)) { + queueTableRows(schemaMock.document, population) + } + + const result = await executeSync('c-1', { + billingAttribution: { workspaceId: 'ws-1' } as never, + }) + + expect(listed.contentReads()).toBe(1) + expect( + dbChainMockFns.from.mock.calls.filter(([table]) => table === schemaMock.document) + ).toHaveLength(expectedDocumentReads) + expectLockGuardedTerminalFailure(result) + await expectNoDocumentWork() + } + ) +}) + describe('classifySuspectListing', () => { it('trusts a healthy listing', () => { expect(classifySuspectListing(100, 100)).toBeNull() @@ -731,6 +1307,107 @@ describe('mergeHydratedDocument', () => { }) }) +describe('mergeHydratedSkippedDocument', () => { + it('keeps the listing hash when hydration reports a synthetic skip hash', () => { + const listed: ExternalDocument = { + externalId: 'transcript-1', + title: 'Weekly sync', + content: '', + contentDeferred: true, + mimeType: 'text/plain', + contentHash: 'fireflies:v2:transcript-1:lifecycle-hash', + metadata: { meetingDate: '2026-08-24T00:00:00.000Z' }, + } + const skipped: ExternalDocument = { + ...listed, + contentDeferred: false, + contentHash: 'fireflies:oversized-response:transcript-1', + skippedReason: 'Transcript response exceeds the safe hydration limit', + metadata: { duration: 45 }, + } + + expect(mergeHydratedSkippedDocument(listed, skipped)).toMatchObject({ + content: '', + contentDeferred: false, + contentHash: listed.contentHash, + skippedReason: skipped.skippedReason, + metadata: { + meetingDate: '2026-08-24T00:00:00.000Z', + duration: 45, + }, + }) + }) + + it('persists an explicit connector retry hash for a skipped hydration', () => { + const listed: ExternalDocument = { + externalId: 'page-1', + title: 'Restricted page', + content: '', + contentDeferred: true, + mimeType: 'text/markdown', + contentHash: 'notion:v3:page-1:unchanged', + } + const skipped: ExternalDocument = { + ...listed, + contentDeferred: false, + skippedReason: 'Nested block is inaccessible', + skippedRetryContentHash: 'notion:retry:v1:page-1', + } + + expect(mergeHydratedSkippedDocument(listed, skipped).contentHash).toBe('notion:retry:v1:page-1') + }) +}) + +describe('requireHydratedListedDocument', () => { + it('turns ambiguous null hydration into a sync failure instead of a silent drop', async () => { + const { requireHydratedListedDocument } = await import('@/lib/knowledge/connectors/sync-engine') + + expect(() => requireHydratedListedDocument(null, 'listed-1')).toThrow( + 'Connector returned no content for listed document listed-1' + ) + }) + + it('passes through a hydrated document', async () => { + const { requireHydratedListedDocument } = await import('@/lib/knowledge/connectors/sync-engine') + const hydrated: ExternalDocument = { + externalId: 'listed-1', + title: 'Listed', + content: 'body', + mimeType: 'text/plain', + } + + expect(requireHydratedListedDocument(hydrated, 'listed-1')).toBe(hydrated) + }) +}) + +describe('recordUnverifiedExistingRefresh', () => { + it('keeps last-known-good content while holding the incremental watermark', async () => { + const { recordUnverifiedExistingRefresh } = await import( + '@/lib/knowledge/connectors/sync-engine' + ) + const result = { docsFailed: 0 } + const failedExternalIds = new Set() + + recordUnverifiedExistingRefresh(result, failedExternalIds, 'existing-1') + + expect(result).toEqual({ docsFailed: 1 }) + expect(failedExternalIds).toEqual(new Set(['existing-1'])) + }) + + it('counts one document once if multiple unusable signals converge', async () => { + const { recordUnverifiedExistingRefresh } = await import( + '@/lib/knowledge/connectors/sync-engine' + ) + const result = { docsFailed: 0 } + const failedExternalIds = new Set() + + recordUnverifiedExistingRefresh(result, failedExternalIds, 'existing-1') + recordUnverifiedExistingRefresh(result, failedExternalIds, 'existing-1') + + expect(result).toEqual({ docsFailed: 1 }) + }) +}) + describe('isStuckDocumentSweepEligible', () => { const now = new Date('2026-08-20T12:00:00.000Z') const minutesBefore = (minutes: number) => new Date(now.getTime() - minutes * 60 * 1000) @@ -740,6 +1417,7 @@ describe('isStuckDocumentSweepEligible', () => { overrides: { processingQueuedAt?: Date | null processingStartedAt?: Date | null + processingDeferredUntil?: Date | null processingCompletedAt?: Date | null uploadedAt?: Date } = {} @@ -747,6 +1425,7 @@ describe('isStuckDocumentSweepEligible', () => { processingStatus, processingQueuedAt: overrides.processingQueuedAt ?? null, processingStartedAt: overrides.processingStartedAt ?? null, + processingDeferredUntil: overrides.processingDeferredUntil ?? null, processingCompletedAt: overrides.processingCompletedAt ?? null, uploadedAt: overrides.uploadedAt ?? minutesBefore(5), }) @@ -806,6 +1485,27 @@ describe('isStuckDocumentSweepEligible', () => { ).toBe(false) }) + it('reclaims a quota-deferred document only after its due time is stale', () => { + expect( + isStuckDocumentSweepEligible( + candidate('pending', { processingDeferredUntil: minutesBefore(239) }), + now + ) + ).toBe(false) + expect( + isStuckDocumentSweepEligible( + candidate('pending', { processingDeferredUntil: minutesBefore(240) }), + now + ) + ).toBe(false) + expect( + isStuckDocumentSweepEligible( + candidate('pending', { processingDeferredUntil: minutesBefore(241) }), + now + ) + ).toBe(true) + }) + it('leaves a failed document alone while its Trigger retries may still run', () => { expect( isStuckDocumentSweepEligible( @@ -916,6 +1616,124 @@ describe('isStuckDocumentSweepEligible', () => { }) }) +describe('selectStuckDocumentSweepCandidates', () => { + const now = new Date('2026-08-20T12:00:00.000Z') + const minutesBefore = (minutes: number) => new Date(now.getTime() - minutes * 60 * 1000) + const oldCandidate = { + processingQueuedAt: minutesBefore(300), + processingStartedAt: null, + processingDeferredUntil: null, + processingCompletedAt: null, + uploadedAt: minutesBefore(600), + } + + it.each([ + { + name: 'fresh queue generation', + stale: { processingStatus: 'pending', ...oldCandidate }, + fresh: { + processingStatus: 'pending', + ...oldCandidate, + processingQueuedAt: minutesBefore(1), + }, + }, + { + name: 'fresh processing claim', + stale: { + processingStatus: 'processing', + ...oldCandidate, + processingStartedAt: minutesBefore(60), + }, + fresh: { + processingStatus: 'processing', + ...oldCandidate, + processingStartedAt: minutesBefore(1), + }, + }, + { + name: 'live quota continuation', + stale: { + processingStatus: 'pending', + ...oldCandidate, + processingDeferredUntil: minutesBefore(300), + }, + fresh: { + processingStatus: 'pending', + ...oldCandidate, + processingDeferredUntil: minutesBefore(1), + }, + }, + { + name: 'fresh failed attempt', + stale: { + processingStatus: 'failed', + ...oldCandidate, + processingCompletedAt: minutesBefore(300), + }, + fresh: { + processingStatus: 'failed', + ...oldCandidate, + processingCompletedAt: minutesBefore(1), + }, + }, + ])( + 'drops a formerly eligible candidate after its locked reread sees a $name', + ({ stale, fresh }) => { + expect( + selectStuckDocumentSweepCandidates([{ id: 'doc-1', ...stale }], now).map((doc) => doc.id) + ).toEqual(['doc-1']) + expect(selectStuckDocumentSweepCandidates([{ id: 'doc-1', ...fresh }], now)).toEqual([]) + } + ) + + it('filters before limiting so old uploads with fresh attempts cannot starve overdue work', () => { + const recentlyRetried = Array.from({ length: 250 }, (_, index) => ({ + id: `recent-${index.toString().padStart(3, '0')}`, + processingStatus: 'pending', + ...oldCandidate, + processingQueuedAt: minutesBefore(1), + })) + const overdue = { + id: 'overdue', + processingStatus: 'pending', + ...oldCandidate, + uploadedAt: minutesBefore(10), + } + + expect(selectStuckDocumentSweepCandidates([...recentlyRetried, overdue], now)).toEqual([ + overdue, + ]) + }) + + it('orders by the status-specific age anchor and uses id as a stable tie-breaker', () => { + const candidates = [ + { + id: 'pending-newer', + processingStatus: 'pending', + ...oldCandidate, + processingQueuedAt: minutesBefore(260), + }, + { + id: 'failed-b', + processingStatus: 'failed', + ...oldCandidate, + processingCompletedAt: minutesBefore(400), + }, + { + id: 'failed-a', + processingStatus: 'failed', + ...oldCandidate, + processingCompletedAt: minutesBefore(400), + }, + ] + + expect( + selectStuckDocumentSweepCandidates(candidates, now).map((candidate) => candidate.id) + ).toEqual(['failed-a', 'failed-b', 'pending-newer']) + expect(stuckDocumentSweepAgeAnchor(candidates[0])).toEqual(minutesBefore(260)) + }) +}) + describe('resolveReconciliationDeleteCap', () => { it('scales with the owned corpus above the absolute floor', async () => { const { resolveReconciliationDeleteCap } = await import( @@ -1188,6 +2006,35 @@ describe('partitionSyncReconciliation — user-excluded documents', () => { }) }) +describe('connectorDocumentSyncTarget', () => { + it('cannot refresh a detached, moved, excluded, or archived document', async () => { + const { connectorDocumentSyncTarget } = await import('@/lib/knowledge/connectors/sync-engine') + + const condition = connectorDocumentSyncTarget('doc-1', 'kb-1', 'connector-1') + for (const [column, value] of [ + [schemaMock.document.id, 'doc-1'], + [schemaMock.document.knowledgeBaseId, 'kb-1'], + [schemaMock.document.connectorId, 'connector-1'], + [schemaMock.document.userExcluded, false], + ] as const) { + expect( + hasMockCondition( + condition, + (node: MockCondition) => + node.type === 'eq' && node.left === column && node.right === value + ) + ).toBe(true) + } + expect( + hasMockCondition( + condition, + (node: MockCondition) => + node.type === 'isNull' && node.column === schemaMock.document.archivedAt + ) + ).toBe(true) + }) +}) + describe('countNonExcludedListed', () => { it('subtracts the excluded documents that appeared in the listing', async () => { const { countNonExcludedListed } = await import('@/lib/knowledge/connectors/sync-engine') @@ -1404,6 +2251,23 @@ describe('buildSyncFailureUpdate', () => { }) }) +describe('buildSyncCapacityUpdate', () => { + it('requires operator action without consuming the transient-failure breaker', async () => { + const { buildSyncCapacityUpdate } = await import('@/lib/knowledge/connectors/sync-engine') + const now = new Date('2026-08-20T00:00:00.000Z') + + expect(buildSyncCapacityUpdate(now, 2, 'source is too large')).toEqual({ + status: 'error', + lastSyncError: 'source is too large', + nextSyncAt: null, + consecutiveFailures: 2, + syncLockToken: null, + syncLockLeaseAt: null, + updatedAt: now, + }) + }) +}) + describe('sync lock lease', () => { const now = new Date('2026-08-20T00:00:00.000Z') @@ -1463,6 +2327,16 @@ describe('buildSyncSuccessUpdate', () => { expect(update.status).toBe('active') expect(update.consecutiveFailures).toBe(0) }) + + it('preserves the incremental watermark when source work failed', async () => { + const { buildSyncSuccessUpdate } = await import('@/lib/knowledge/connectors/sync-engine') + + const update = buildSyncSuccessUpdate(now, 42, null, null, false) + + expect(update).not.toHaveProperty('lastSyncAt') + expect(update.status).toBe('active') + expect(update.nextSyncAt).toBeNull() + }) }) describe('completeSyncLog', () => { @@ -1479,7 +2353,9 @@ describe('completeSyncLog', () => { docsUpdated: 0, docsDeleted: 0, docsUnchanged: 0, + docsSkipped: 0, docsFailed: 0, + processingDispatch: { requested: 0, accepted: 0, failed: 0 }, }) const where = dbChainMockFns.where.mock.calls[0][0] @@ -1506,6 +2382,99 @@ describe('completeSyncLog', () => { ) ).toBe(true) }) + + it('persists skipped and failed source outcomes separately', async () => { + const { completeSyncLog } = await import('@/lib/knowledge/connectors/sync-engine') + + await completeSyncLog('log-1', 'completed', { + docsAdded: 0, + docsUpdated: 0, + docsDeleted: 0, + docsUnchanged: 0, + docsSkipped: 3, + docsFailed: 2, + processingDispatch: { requested: 0, accepted: 0, failed: 0 }, + }) + + expect(dbChainMockFns.set).toHaveBeenCalledWith( + expect.objectContaining({ docsSkipped: 3, docsFailed: 2 }) + ) + }) +}) + +describe('completeSuccessfulSync', () => { + const RESULT = { + docsAdded: 1, + docsUpdated: 0, + docsDeleted: 0, + docsUnchanged: 0, + docsSkipped: 0, + docsFailed: 1, + processingDispatch: { requested: 0, accepted: 0, failed: 0 }, + } + + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + + it('commits the completed log and connector state in one guarded transaction', async () => { + const { completeSuccessfulSync } = await import('@/lib/knowledge/connectors/sync-engine') + + queueTableRows(schemaMock.knowledgeBase, [{ id: 'kb-1' }]) + queueTableRows(schemaMock.knowledgeConnector, [{ id: 'c-1' }]) + queueTableRows(schemaMock.document, [{ count: 4 }]) + dbChainMockFns.returning + .mockResolvedValueOnce([{ id: 'log-1' }]) + .mockResolvedValueOnce([{ id: 'c-1' }]) + + await expect(completeSuccessfulSync('c-1', 'kb-1', 'log-1', 60, RESULT, null)).resolves.toBe( + true + ) + + expect(dbChainMockFns.transaction).toHaveBeenCalledTimes(1) + expect(dbChainMockFns.set).toHaveBeenCalledWith( + expect.objectContaining({ status: 'completed', docsFailed: 1 }) + ) + const connectorUpdate = dbChainMockFns.set.mock.calls.find( + (call) => (call[0] as Record | undefined)?.status === 'active' + )?.[0] as Record | undefined + expect(connectorUpdate).toBeDefined() + expect(connectorUpdate).not.toHaveProperty('lastSyncAt') + }) + + it('publishes neither terminal state when lock ownership is gone', async () => { + const { completeSuccessfulSync } = await import('@/lib/knowledge/connectors/sync-engine') + + queueTableRows(schemaMock.knowledgeBase, [{ id: 'kb-1' }]) + queueTableRows(schemaMock.knowledgeConnector, []) + + await expect(completeSuccessfulSync('c-1', 'kb-1', 'log-1', 60, RESULT, null)).resolves.toBe( + false + ) + + expect(dbChainMockFns.set).not.toHaveBeenCalled() + }) + + it('does not publish connector state when the guarded log close is refused', async () => { + const { completeSuccessfulSync } = await import('@/lib/knowledge/connectors/sync-engine') + + queueTableRows(schemaMock.knowledgeBase, [{ id: 'kb-1' }]) + queueTableRows(schemaMock.knowledgeConnector, [{ id: 'c-1' }]) + queueTableRows(schemaMock.document, [{ count: 4 }]) + dbChainMockFns.returning.mockResolvedValueOnce([]) + + await expect(completeSuccessfulSync('c-1', 'kb-1', 'log-1', 60, RESULT, null)).resolves.toBe( + false + ) + + expect(dbChainMockFns.set).toHaveBeenCalledWith( + expect.objectContaining({ status: 'completed' }) + ) + expect(dbChainMockFns.set).not.toHaveBeenCalledWith( + expect.objectContaining({ status: 'active' }) + ) + }) }) describe('stillHoldsSyncLock', () => { @@ -1640,8 +2609,7 @@ describe('markSyncSuperseded', () => { '@/lib/knowledge/connectors/sync-engine' ) - // The task wrapper reports `success: !result.error`. - expect(markSyncSuperseded(result).error).toBe(SUPERSEDED_SYNC_ERROR) + expect(markSyncSuperseded(result).skipReason).toBe(SUPERSEDED_SYNC_ERROR) }) it('preserves the document counters of the discarded run', async () => { @@ -1858,6 +2826,29 @@ describe('heartbeatSyncLock', () => { dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'c-1' }]) expect(await heartbeatSyncLock('c-1', 'run-a')).toBe(true) }) + + it('can require the connector to remain live before destructive follow-up work', async () => { + const { heartbeatLiveSyncLock } = await import('@/lib/knowledge/connectors/sync-engine') + + dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'c-1' }]) + expect(await heartbeatLiveSyncLock('c-1', 'run-a')).toBe(true) + + const where = dbChainMockFns.where.mock.calls[0][0] + expect( + hasMockCondition( + where, + (node: MockCondition) => + node.type === 'isNull' && node.column === schemaMock.knowledgeConnector.archivedAt + ) + ).toBe(true) + expect( + hasMockCondition( + where, + (node: MockCondition) => + node.type === 'isNull' && node.column === schemaMock.knowledgeConnector.deletedAt + ) + ).toBe(true) + }) }) describe('executeSync heartbeats during the listing phase', () => { @@ -1921,7 +2912,7 @@ describe('executeSync heartbeats during the listing phase', () => { // Aborted on the beat before page 2 rather than paging on under a lost lock. expect(mockListDocuments).toHaveBeenCalledTimes(1) - expect(result.error).toBe('sync_superseded') + expect(result.skipReason).toBe('sync_superseded') }) it('does not beat when pages return faster than the interval', async () => { @@ -2053,10 +3044,15 @@ describe('executeSync hard-delete reconciliation', () => { vi.useRealTimers() }) - /** Primes every read the reconciliation path makes, in the order it makes them. */ + /** + * Primes every reconciliation read in order, including the unconditional + * ownership check inside the destructive transaction. + */ function primeReconciliation() { queueTableRows(schemaMock.knowledgeConnector, [CONNECTOR]) + queueTableRows(schemaMock.knowledgeConnector, [{ id: 'c-1' }]) queueTableRows(schemaMock.knowledgeBase, [{ userId: 'u-1', workspaceId: 'ws-1' }]) + queueTableRows(schemaMock.knowledgeBase, [{ id: 'kb-1' }]) // hasTombstonedDocs, then existingDocs / tombstonedDocs / excludedDocs. queueTableRows(schemaMock.document, []) queueTableRows(schemaMock.document, ownedDocs) @@ -2067,7 +3063,7 @@ describe('executeSync hard-delete reconciliation', () => { schemaMock.document, missingIds.map((id) => ({ id })) ) - dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'c-1' }]) + dbChainMockFns.returning.mockResolvedValueOnce([CONNECTOR]) mockListDocuments.mockResolvedValue({ documents: ownedDocs.slice(0, LISTED_DOC_COUNT).map((d) => ({ @@ -2110,6 +3106,30 @@ describe('executeSync hard-delete reconciliation', () => { expect(calls.flatMap((call) => call[0] as string[])).toEqual(missingIds) }) + it('stops deletion between chunks when the sync lock was reclaimed', async () => { + const { executeSync } = await import('@/lib/knowledge/connectors/sync-engine') + const { SYNC_LOCK_HEARTBEAT_INTERVAL_MS } = await import( + '@/lib/knowledge/connectors/sync-limits' + ) + const { hardDeleteDocuments } = await import('@/lib/knowledge/documents/service') + + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-08-25T00:00:00.000Z')) + primeReconciliation() + vi.mocked(hardDeleteDocuments).mockImplementation(async () => { + vi.setSystemTime(new Date(Date.now() + SYNC_LOCK_HEARTBEAT_INTERVAL_MS + 1)) + return 0 + }) + + const result = await executeSync('c-1', { + billingAttribution: { workspaceId: 'ws-1' } as never, + fullSync: true, + }) + + expect(hardDeleteDocuments).toHaveBeenCalledTimes(1) + expect(result.skipReason).toBe('sync_superseded') + }) + it('bounds and orders the stuck-document sweep instead of draining a backlog at once', async () => { const { executeSync, STUCK_RETRY_MAX_CANDIDATES_PER_SYNC } = await import( '@/lib/knowledge/connectors/sync-engine' @@ -2166,7 +3186,7 @@ describe('executeSync hard-delete reconciliation', () => { * without releasing the token and lease left a row that was neither locked * nor reclaimable — the reaper only looks at `syncing` rows. */ - expect(result.error).toBe('knowledge_base_deleted') + expect(result.skipReason).toBe('knowledge_base_deleted') expect(dbChainMockFns.set).toHaveBeenCalledWith( expect.objectContaining({ status: 'error', @@ -2176,24 +3196,11 @@ describe('executeSync hard-delete reconciliation', () => { ) }) - it('lets a heartbeat run between chunks of a long purge', async () => { + it('passes a transactional sync-lock guard to every hard-delete chunk', async () => { const { executeSync } = await import('@/lib/knowledge/connectors/sync-engine') const { hardDeleteDocuments } = await import('@/lib/knowledge/documents/service') - const { SYNC_LOCK_HEARTBEAT_INTERVAL_MS } = await import( - '@/lib/knowledge/connectors/sync-limits' - ) - - vi.useFakeTimers() - vi.setSystemTime(new Date('2026-08-20T00:00:00.000Z')) primeReconciliation() - - // Each chunk takes longer than the heartbeat interval, which is the case - // chunking exists for: without a beat between them the reaper reclaims a - // connector whose purge is still running. - vi.mocked(hardDeleteDocuments).mockImplementation(async () => { - vi.setSystemTime(new Date(Date.now() + SYNC_LOCK_HEARTBEAT_INTERVAL_MS + 1_000)) - return 0 - }) + vi.mocked(hardDeleteDocuments).mockResolvedValue(0) dbChainMockFns.returning.mockResolvedValue([{ id: 'c-1' }]) await executeSync('c-1', { @@ -2201,11 +3208,13 @@ describe('executeSync hard-delete reconciliation', () => { fullSync: true, }) - const beats = dbChainMockFns.set.mock.calls.filter( - (call) => (call[0] as Record | undefined)?.syncLockLeaseAt instanceof Date - ) - // One for the lock acquisition, then at least one more from inside the loop. - expect(beats.length).toBeGreaterThan(1) + for (const call of vi.mocked(hardDeleteDocuments).mock.calls) { + expect(call[4]).toEqual({ + connectorId: 'c-1', + knowledgeBaseId: 'kb-1', + syncLockToken: expect.any(String), + }) + } }) }) @@ -2215,7 +3224,9 @@ describe('completeSyncLog ownership guard', () => { docsUpdated: 0, docsDeleted: 0, docsUnchanged: 0, + docsSkipped: 0, docsFailed: 0, + processingDispatch: { requested: 0, accepted: 0, failed: 0 }, } beforeEach(() => { @@ -2346,7 +3357,7 @@ describe('executeSync terminal exits under a lost lock', () => { dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'c-1' }]) } - it('skips the success state write when its guarded log close is refused', async () => { + it('skips the success state write when the terminal knowledge-base lock is refused', async () => { const { executeSync } = await import('@/lib/knowledge/connectors/sync-engine') primeLockedRun() @@ -2360,32 +3371,22 @@ describe('executeSync terminal exits under a lost lock', () => { queueTableRows(schemaMock.knowledgeConnector, [ { connectorArchivedAt: null, connectorDeletedAt: null, kbDeletedAt: null }, ]) - // Every later `.returning()` falls through to the empty default, so the - // guarded log close matches no row — the run no longer owns its outcome. mockListDocuments.mockResolvedValue({ documents: [], hasMore: false }) const result = await executeSync('c-1', { billingAttribution: { workspaceId: 'ws-1' } as never, }) - expect(result.error).toBe('sync_superseded') + expect(result.skipReason).toBe('sync_superseded') /** - * A refused close means the run no longer owns the outcome it was about to - * publish, which is exactly what the terminal connector write would have - * rejected two statements later. Short-circuiting there keeps the reported - * outcome identical while skipping the intervening document count. + * Refusing the first terminal lock prevents the completed log and connector + * state from becoming visible independently. */ expect(dbChainMockFns.set).not.toHaveBeenCalledWith( expect.objectContaining({ status: 'active', consecutiveFailures: 0 }) ) - - // The success call site is the one that must ask for the guard. - expect( - dbChainMockFns.where.mock.calls.some((call) => - hasMockCondition(call[0], (node: MockCondition) => node.type === 'exists') - ) - ).toBe(true) + expect(dbChainMockFns.for).toHaveBeenCalledWith('update') }) it('releases the lock on a connector archived out from under the run', async () => { @@ -2424,7 +3425,7 @@ describe('executeSync terminal exits under a lost lock', () => { billingAttribution: { workspaceId: 'ws-1' } as never, }) - expect(result.error).toBe('Connector deleted during sync') + expect(result.skipReason).toBe('connector_deleted_during_sync') /** * This exit wrote nothing to the connector row, leaving it `syncing` with a diff --git a/apps/sim/lib/knowledge/connectors/sync-engine.ts b/apps/sim/lib/knowledge/connectors/sync-engine.ts index f90e588ee9e..84d890db819 100644 --- a/apps/sim/lib/knowledge/connectors/sync-engine.ts +++ b/apps/sim/lib/knowledge/connectors/sync-engine.ts @@ -22,6 +22,7 @@ import { isNull, lt, ne, + or, sql, } from 'drizzle-orm' import { decryptApiKey } from '@/lib/api-key/crypto' @@ -38,8 +39,13 @@ import { MAX_CONSECUTIVE_FAILURES, SYNC_LOCK_HEARTBEAT_INTERVAL_MS, } from '@/lib/knowledge/connectors/sync-limits' +import { DOCUMENT_PROCESSING_STALE_THRESHOLD_MS } from '@/lib/knowledge/documents/processing-timeouts.server' import type { DocumentData } from '@/lib/knowledge/documents/service' -import { hardDeleteDocuments, processDocumentsWithQueue } from '@/lib/knowledge/documents/service' +import { + ConnectorSyncDeletionGuardError, + hardDeleteDocuments, + processDocumentsWithQueue, +} from '@/lib/knowledge/documents/service' import { type DocumentProcessingStatus, isDocumentProcessingStatus, @@ -85,8 +91,8 @@ class ConnectorDeletedException extends Error { } const SYNC_BATCH_SIZE = 5 -/** Estimated source bytes for a doc whose listing did not report a size. */ -const DEFAULT_OP_SIZE_BYTES = 4 * 1024 * 1024 +/** Unknown deferred downloads run alone; actual connector files can reach this budget. */ +const DEFAULT_OP_SIZE_BYTES = 64 * 1024 * 1024 /** * Max summed source bytes hydrated/uploaded concurrently within a batch. Each * in-flight file materializes as a content string plus an upload buffer, so this @@ -96,6 +102,21 @@ const DEFAULT_OP_SIZE_BYTES = 4 * 1024 * 1024 */ const CONTENT_INFLIGHT_BUDGET_BYTES = 64 * 1024 * 1024 const MAX_PAGES = 500 +/** + * Maximum documents retained in either the source corpus or owned corpus. + * + * The engine needs the complete source identity set and the connector's complete + * owned-document set at the same time to distinguish adds from updates and to + * reconcile deletions safely. Page-count limits alone do not bound that working + * set: a connector page can contain many documents, and an incremental connector + * can accumulate a corpus much larger than its current page. The two corpora + * coexist, so the row-count peak is twice this value plus bounded + * maps and operation references. Crossing either per-corpus ceiling fails before + * document writes. + */ +export const CONNECTOR_SYNC_MAX_CORPUS_DOCUMENTS = 50_000 + +export const CONNECTOR_SYNC_MAX_SOURCE_PAYLOAD_BYTES = 256 * 1024 * 1024 const MAX_SAFE_TITLE_LENGTH = 200 /** * How many stuck documents are re-dispatched per call. @@ -139,6 +160,7 @@ export const STUCK_RETRY_MAX_CANDIDATES_PER_SYNC = 200 * large enough that the per-call overhead stays negligible. */ const HARD_DELETE_CHUNK_SIZE = 25 +const CONNECTOR_DELETION_CLEANUP_BATCH_SIZE = 250 /** * Concurrent `knowledge-process-document` runs, shared by every workspace. * @@ -148,40 +170,76 @@ const HARD_DELETE_CHUNK_SIZE = 25 */ const PROCESSING_QUEUE_CONCURRENCY = envNumber(env.KB_CONFIG_CONCURRENCY_LIMIT, 20) +class ConnectorSyncCapacityError extends Error {} + +class ConnectorSyncWorkingSetLimitError extends ConnectorSyncCapacityError { + constructor(connectorId: string, scope: 'source listing' | 'owned corpus') { + super( + `Connector ${connectorId} ${scope} exceeds the safe per-corpus limit of ${CONNECTOR_SYNC_MAX_CORPUS_DOCUMENTS.toLocaleString()} documents. Narrow the configured source scope or set a connector document limit before syncing again.` + ) + this.name = 'ConnectorSyncWorkingSetLimitError' + } +} + /** - * Worst-case wall clock for one document's processing: the task's own duration - * ceiling times its retry budget, both read from the env vars - * `knowledge-process-document` is configured with. + * Returns a query's sentinel-inclusive limit for the remaining working-set + * budget. The extra row proves the corpus exceeded the cap without loading the + * rest of it. */ -export function worstCaseProcessingMinutes( - maxDurationSeconds: number, - maxAttempts: number -): number { - return (maxDurationSeconds * maxAttempts) / 60 +export function syncWorkingSetQueryLimit(rowsAlreadyLoaded: number): number { + return Math.max(CONNECTOR_SYNC_MAX_CORPUS_DOCUMENTS - rowsAlreadyLoaded, 0) + 1 +} + +export function sourcePageFitsSyncWorkingSet(rowsAlreadyLoaded: number, pageRows: number): boolean { + return rowsAlreadyLoaded + pageRows <= CONNECTOR_SYNC_MAX_CORPUS_DOCUMENTS } -/** Headroom over the worst case, so ordinary jitter never reclaims a live run. */ -const STALE_PROCESSING_HEADROOM = 1.5 +function assertSyncWorkingSetWithinLimit( + connectorId: string, + rowsAlreadyLoaded: number, + rowsJustLoaded: number +): void { + if (rowsAlreadyLoaded + rowsJustLoaded > CONNECTOR_SYNC_MAX_CORPUS_DOCUMENTS) { + throw new ConnectorSyncWorkingSetLimitError(connectorId, 'owned corpus') + } +} -/** Floor preserving the previously hard-coded value at the default env. */ -const STALE_PROCESSING_FLOOR_MINUTES = 45 +function retainedExternalDocumentBytes(doc: ExternalDocument): number { + let bytes = Buffer.byteLength(doc.externalId) + Buffer.byteLength(doc.title) + bytes += Buffer.byteLength(doc.content ?? '') + bytes += Buffer.byteLength(doc.sourceUrl ?? '') + bytes += Buffer.byteLength(doc.contentHash ?? '') + if (doc.sourceFile?.bytes) bytes += doc.sourceFile.bytes.byteLength + try { + bytes += Buffer.byteLength(JSON.stringify(doc.metadata ?? {})) + } catch { + bytes += DEFAULT_OP_SIZE_BYTES + } + return bytes +} -/** - * Minutes a `processing` document is given before the sweep calls its run - * abandoned. Never below the worst case a legitimate run can take. - */ -export function resolveStaleProcessingMinutes( - maxDurationSeconds: number, - maxAttempts: number +/** Fails listing before the engine retains an unbounded inline-content corpus. */ +export function addSourcePagePayloadBytes( + retainedBytes: number, + documents: ExternalDocument[] ): number { - return Math.max( - STALE_PROCESSING_FLOOR_MINUTES, - Math.ceil( - worstCaseProcessingMinutes(maxDurationSeconds, maxAttempts) * STALE_PROCESSING_HEADROOM - ) - ) + let nextBytes = retainedBytes + for (const doc of documents) { + nextBytes += retainedExternalDocumentBytes(doc) + if (nextBytes > CONNECTOR_SYNC_MAX_SOURCE_PAYLOAD_BYTES) { + throw new ConnectorSyncCapacityError( + `Connector source listing exceeds the safe retained-payload limit of ${CONNECTOR_SYNC_MAX_SOURCE_PAYLOAD_BYTES.toLocaleString()} bytes. Use a narrower source scope or a deferred-content connector.` + ) + } + } + return nextBytes } +export { + resolveStaleProcessingMinutes, + worstCaseProcessingMinutes, +} from '@/lib/knowledge/documents/types' + /** * How long a document may sit in `processing` before the sweep treats its run as * abandoned — and deletes its embeddings and re-dispatches it. @@ -195,10 +253,7 @@ export function resolveStaleProcessingMinutes( * pass. Deriving it keeps the invariant true at any configuration; the floor * preserves today's value at the defaults. */ -const STALE_PROCESSING_MINUTES = resolveStaleProcessingMinutes( - envNumber(env.KB_CONFIG_MAX_DURATION, 600), - envNumber(env.KB_CONFIG_MAX_ATTEMPTS, 3) -) +const STALE_PROCESSING_MINUTES = DOCUMENT_PROCESSING_STALE_THRESHOLD_MS / (60 * 1000) const RETRY_WINDOW_DAYS = 7 const RUNNABLE_CONNECTOR_STATUSES = ['active', 'error'] as const @@ -223,6 +278,7 @@ export interface StuckDocumentSweepCandidate { processingStatus: DocumentProcessingStatus processingQueuedAt: Date | null processingStartedAt: Date | null + processingDeferredUntil: Date | null processingCompletedAt: Date | null uploadedAt: Date } @@ -266,19 +322,13 @@ export interface StuckDocumentSweepCandidate { * never lost. The user-facing retry stays immediate — it writes `pending` and * dispatches without consulting the sweep at all. * - * This narrows the duplicate-dispatch window; it cannot close it. A grace - * period is a timing guarantee, and no timing guarantee is a correctness one: - * a document queued for longer than the grace is still reclaimed while its - * original run waits, and Trigger.dev will not deduplicate the second dispatch - * because the idempotency key `processDocumentsWithQueue` uses is - * `doc-process--` with a fresh `requestId` per dispatch - * — scoped per dispatch by design, so it blocks intra-dispatch retries and - * nothing else. Each duplicate run mints its own indexing pass and bills for - * it, which is the double-billing `451d2ccbde` closed for the inline path. - * Closing it durably needs state, not timing: a document-scoped idempotency - * key, or a dispatch-generation column the worker carries and checks before - * indexing, so a superseded run declines to bill. That is a larger change than - * this hotfix. + * The grace decides when a queued run may be superseded, but correctness does + * not depend on that timing judgment. Every task carries the queue stamp its + * dispatch installed and must match it before claiming or billing the row. A + * sweep clears the abandoned stamp before installing a new one, so a late old + * task declines while the replacement proceeds. Queue admission also claims + * only an empty stamp, preventing concurrent callers from charging or enqueuing + * two live generations for the same pending document. */ export function isStuckDocumentSweepEligible(doc: StuckDocumentSweepCandidate, now: Date): boolean { switch (doc.processingStatus) { @@ -288,6 +338,9 @@ export function isStuckDocumentSweepEligible(doc: StuckDocumentSweepCandidate, n return now.getTime() - lastAttemptEndedAt.getTime() > QUEUED_DISPATCH_GRACE_MS } case 'pending': { + if (doc.processingDeferredUntil) { + return now.getTime() - doc.processingDeferredUntil.getTime() > QUEUED_DISPATCH_GRACE_MS + } const queuedAt = doc.processingQueuedAt ?? doc.uploadedAt return now.getTime() - queuedAt.getTime() > QUEUED_DISPATCH_GRACE_MS } @@ -304,6 +357,32 @@ export function isStuckDocumentSweepEligible(doc: StuckDocumentSweepCandidate, n } } +export function stuckDocumentSweepAgeAnchor(doc: StuckDocumentSweepCandidate): Date { + switch (doc.processingStatus) { + case 'failed': + return doc.processingCompletedAt ?? doc.processingQueuedAt ?? doc.uploadedAt + case 'pending': + return doc.processingDeferredUntil ?? doc.processingQueuedAt ?? doc.uploadedAt + case 'processing': + return doc.processingStartedAt ?? new Date(0) + case 'completed': + return doc.uploadedAt + } +} + +export function selectStuckDocumentSweepCandidates< + T extends StuckDocumentSweepCandidate & { id: string }, +>(documents: T[], now: Date, limit = STUCK_RETRY_MAX_CANDIDATES_PER_SYNC): T[] { + return documents + .filter((doc) => isStuckDocumentSweepEligible(doc, now)) + .sort((left, right) => { + const ageOrder = + stuckDocumentSweepAgeAnchor(left).getTime() - stuckDocumentSweepAgeAnchor(right).getTime() + return ageOrder || left.id.localeCompare(right.id) + }) + .slice(0, limit) +} + /** Sanitizes a document title for use in S3 storage keys. */ function sanitizeStorageTitle(title: string): string { return title.replace(/[^a-zA-Z0-9.-]/g, '_').slice(0, MAX_SAFE_TITLE_LENGTH) @@ -366,12 +445,12 @@ type KnowledgeBaseLockingTx = Pick type DocOp = | { type: 'add'; extDoc: ExternalDocument } | { type: 'update'; existingId: string; extDoc: ExternalDocument } - | { type: 'skip'; extDoc: ExternalDocument } + | { type: 'skip'; existingId?: string; extDoc: ExternalDocument } type DocClassification = | { type: 'add' } | { type: 'update'; existingId: string } - | { type: 'skip' } + | { type: 'skip'; existingId?: string } | { type: 'unchanged' } | { type: 'drop' } @@ -379,10 +458,13 @@ type DocClassification = * Decides what a listed external document becomes during reconciliation. * * - `skip`: connector flagged it (e.g. too large) and it is not already indexed — - * record a visible `failed` document instead of dropping it silently. A file that - * is already indexed is kept as-is (last-known-good) rather than downgraded. + * record a visible `failed` document instead of dropping it silently. Existing + * content stays last-known-good unless the connector marks the skip authoritative. * - `drop`: empty, non-deferred content that cannot be indexed. * - `add` / `update` / `unchanged`: normal content reconciliation by content hash. + * - A deferred listing always rehydrates an existing content-less placeholder, + * even when its listing hash is unchanged, so a prior hydration-time skip can + * recover when the source becomes indexable. * * `forceRehydrate` (set on a full resync of a `rehydrateOnFullSync` connector) promotes * an otherwise-`unchanged` deferred document to `update` so its content is re-fetched — @@ -393,13 +475,21 @@ type DocClassification = export function classifyExternalDoc( extDoc: Pick< ExternalDocument, - 'content' | 'sourceFile' | 'contentDeferred' | 'contentHash' | 'skippedReason' + | 'content' + | 'sourceFile' + | 'contentDeferred' + | 'contentHash' + | 'skippedReason' + | 'skippedExistingDisposition' >, - existing: { id: string; contentHash: string | null } | undefined, + existing: { id: string; contentHash: string | null; storageKey?: string | null } | undefined, forceRehydrate = false ): DocClassification { if (extDoc.skippedReason) { - return existing ? { type: 'unchanged' } : { type: 'skip' } + if (!existing) return { type: 'skip' } + return existing.storageKey === null || extDoc.skippedExistingDisposition === 'replace' + ? { type: 'skip', existingId: existing.id } + : { type: 'unchanged' } } if (!hasIndexablePayload(extDoc) && !extDoc.contentDeferred) { return { type: 'drop' } @@ -407,6 +497,9 @@ export function classifyExternalDoc( if (!existing) { return { type: 'add' } } + if (existing.storageKey === null && extDoc.contentDeferred) { + return { type: 'update', existingId: existing.id } + } if (existing.contentHash !== extDoc.contentHash) { return { type: 'update', existingId: existing.id } } @@ -447,11 +540,70 @@ export function mergeHydratedDocument( } } -/** Estimated source bytes for a pending op, taken from its listing metadata. */ +/** + * Merges a hydration-time skip marker onto its listing stub. + * + * A skipped hydration did not verify indexable content, so its provider-specific + * fallback hash cannot supersede the listing hash used by the next sync's change + * classification. Keeping the listing hash makes a newly persisted skip stable + * until the source metadata changes. A connector can explicitly provide + * `skippedRetryContentHash` when the skip must be retried independently of that + * metadata, such as a Notion nested block whose access changes without editing + * its parent page. + */ +export function mergeHydratedSkippedDocument( + stub: ExternalDocument, + hydrated: ExternalDocument +): ExternalDocument { + return { + ...stub, + content: '', + contentHash: hydrated.skippedRetryContentHash ?? stub.contentHash, + contentDeferred: false, + skippedReason: hydrated.skippedReason, + skippedExistingDisposition: hydrated.skippedExistingDisposition, + metadata: { ...stub.metadata, ...hydrated.metadata }, + } +} + +/** + * A listed deferred document is known to exist at listing time. A null hydration + * is therefore ambiguous provider failure, not authoritative deletion: treating + * it as a successful drop can advance an incremental watermark past a document + * that merely became inaccessible. + */ +export function requireHydratedListedDocument( + document: ExternalDocument | null, + externalId: string +): ExternalDocument { + if (!document) { + throw new Error(`Connector returned no content for listed document ${externalId}`) + } + return document +} + +/** + * Records a source update that was observed but could not be verified or + * persisted. The stored document remains last-known-good, while `docsFailed` + * prevents an incremental watermark from advancing past the consumed change. + */ +export function recordUnverifiedExistingRefresh( + result: Pick, + failedExternalIds: Set, + externalId: string +): void { + if (failedExternalIds.has(externalId)) return + failedExternalIds.add(externalId) + result.docsFailed++ +} + +/** Actual retained bytes when available, otherwise a conservative deferred estimate. */ function estimateOpSizeBytes(op: DocOp): number { // Skip ops load no content (just a row insert), so they do not count against the // in-flight content budget. if (op.type === 'skip') return 0 + if (op.extDoc.sourceFile?.bytes) return op.extDoc.sourceFile.bytes.byteLength + if (op.extDoc.content) return Buffer.byteLength(op.extDoc.content) const size = op.extDoc.metadata?.fileSize ?? op.extDoc.metadata?.size return typeof size === 'number' && Number.isFinite(size) && size > 0 ? size @@ -620,6 +772,7 @@ export async function completeSyncLog( docsUpdated: result.docsUpdated, docsDeleted: result.docsDeleted, docsUnchanged: result.docsUnchanged, + docsSkipped: result.docsSkipped, docsFailed: result.docsFailed, }) .where( @@ -636,6 +789,101 @@ export async function completeSyncLog( return closed.length > 0 } +class SyncCompletionOwnershipLost extends Error { + constructor() { + super('Connector sync no longer owns its terminal state') + this.name = 'SyncCompletionOwnershipLost' + } +} + +/** + * Atomically publishes the completed log and connector terminal state. + * + * The knowledge base is locked first to match lifecycle mutations, then the + * connector lock is verified under `FOR UPDATE`. A completed log can therefore + * never become visible unless the matching connector state commits with it. + */ +export async function completeSuccessfulSync( + connectorId: string, + knowledgeBaseId: string, + syncLogId: string, + syncIntervalMinutes: number, + result: SyncResult, + reconciliationHoldNotice: string | null +): Promise { + try { + return await db.transaction(async (tx) => { + const [lockedKnowledgeBase] = await tx + .select({ id: knowledgeBase.id }) + .from(knowledgeBase) + .where(and(eq(knowledgeBase.id, knowledgeBaseId), isNull(knowledgeBase.deletedAt))) + .for('update') + if (!lockedKnowledgeBase) throw new SyncCompletionOwnershipLost() + + const [lockedConnector] = await tx + .select({ id: knowledgeConnector.id }) + .from(knowledgeConnector) + .where(stillHoldsSyncLock(connectorId, syncLogId)) + .for('update') + if (!lockedConnector) throw new SyncCompletionOwnershipLost() + + const [{ count: actualDocCount }] = await tx + .select({ count: sql`count(*)::int` }) + .from(document) + .where( + and( + eq(document.connectorId, connectorId), + eq(document.userExcluded, false), + isNull(document.archivedAt), + isNull(document.deletedAt) + ) + ) + + const now = new Date() + const [closedLog] = await tx + .update(knowledgeConnectorSyncLog) + .set({ + status: 'completed', + completedAt: now, + docsAdded: result.docsAdded, + docsUpdated: result.docsUpdated, + docsDeleted: result.docsDeleted, + docsUnchanged: result.docsUnchanged, + docsSkipped: result.docsSkipped, + docsFailed: result.docsFailed, + }) + .where( + and( + eq(knowledgeConnectorSyncLog.id, syncLogId), + eq(knowledgeConnectorSyncLog.status, 'started') + ) + ) + .returning({ id: knowledgeConnectorSyncLog.id }) + if (!closedLog) throw new SyncCompletionOwnershipLost() + + const [writtenConnector] = await tx + .update(knowledgeConnector) + .set( + buildSyncSuccessUpdate( + now, + actualDocCount, + calculateNextSyncTime(syncIntervalMinutes), + reconciliationHoldNotice, + result.docsFailed === 0 + ) + ) + .where(stillHoldsSyncLock(connectorId, syncLogId)) + .returning({ id: knowledgeConnector.id }) + if (!writtenConnector) throw new SyncCompletionOwnershipLost() + + return true + }) + } catch (error) { + if (error instanceof SyncCompletionOwnershipLost) return false + throw error + } +} + /** * Matches the connector row only while this run still holds its sync lock. * @@ -746,19 +994,37 @@ export function shouldHeartbeatSyncLock( * return means the lock was reclaimed and this run must stop rather than keep * writing alongside its replacement. */ -export async function heartbeatSyncLock( - connectorId: string, - syncLockToken: string +async function writeSyncHeartbeat( + condition: ReturnType ): Promise { const beat = await db .update(knowledgeConnector) .set({ syncLockLeaseAt: new Date() }) - .where(holdsSyncLockToken(connectorId, syncLockToken)) + .where(condition) .returning({ id: knowledgeConnector.id }) return beat.length > 0 } +export async function heartbeatSyncLock( + connectorId: string, + syncLockToken: string +): Promise { + return writeSyncHeartbeat(holdsSyncLockToken(connectorId, syncLockToken)) +} + +/** + * Extends the lease only while this run owns a live connector. Destructive + * follow-up work uses this stricter probe immediately before dispatch so a run + * reclaimed after its transaction cannot enqueue alongside its replacement. + */ +export async function heartbeatLiveSyncLock( + connectorId: string, + syncLockToken: string +): Promise { + return writeSyncHeartbeat(stillHoldsSyncLock(connectorId, syncLockToken)) +} + /** Columns a terminal write may set. Both paths write a subset of the same set. */ type ConnectorTerminalUpdate = Partial @@ -834,12 +1100,11 @@ async function releaseSyncLockOnDeletedConnector( export const SUPERSEDED_SYNC_ERROR = 'sync_superseded' /** - * Marks a superseded run so the task wrapper's `success: !result.error` does not - * report a discarded run as a clean sync — the same reason a lock-contended run - * returns `sync_in_progress` rather than an empty success. + * Marks a superseded run with typed control flow so provider diagnostics can + * never collide with a lifecycle reason. */ export function markSyncSuperseded(result: SyncResult): SyncResult { - return { ...result, error: SUPERSEDED_SYNC_ERROR } + return { ...result, skipReason: SUPERSEDED_SYNC_ERROR } } /** @@ -850,6 +1115,7 @@ export function markSyncSuperseded(result: SyncResult): SyncResult { * - never on incremental syncs (they list only changed documents) * - never when the engine truncated pagination (`listingTruncated`) — a forced * fullSync cannot fix truncation, so it cannot override it + * - never when a provider declares its pagination non-authoritative * - not when a connector capped its listing (`listingCapped`), unless a forced * fullSync deliberately overrides the cap to reconcile the capped scope */ @@ -860,6 +1126,7 @@ export function shouldReconcileDeletions( ): boolean { if (isIncremental) return false if (syncContext?.listingTruncated) return false + if (syncContext?.reconciliationUnsafe) return false return !syncContext?.listingCapped || Boolean(fullSync) } @@ -955,7 +1222,7 @@ export function classifySuspectListing( /** * Decides whether a suspect listing may still reconcile deletions. * - * A suspect listing is only acted on once the *same* observation repeats on a + * A suspect listing is only acted on after a consecutive suspect observation, so a * consecutive sync, so a single transient upstream fault can never remove * documents — not even reversibly, since a soft delete hides them from search * immediately. A genuinely emptied source keeps reconciling: its second sync @@ -1072,6 +1339,27 @@ export function buildSyncFailureUpdate( } } +/** + * A deterministic capacity rejection needs operator action, not an automatic + * retry or the transient-failure circuit breaker. Keep its precise diagnostic, + * release the lock, and leave the connector manually runnable. + */ +export function buildSyncCapacityUpdate( + now: Date, + previousFailures: number | null | undefined, + errorMessage: string +) { + return { + status: 'error' as const, + lastSyncError: errorMessage, + nextSyncAt: null, + consecutiveFailures: previousFailures ?? 0, + syncLockToken: null, + syncLockLeaseAt: null, + updatedAt: now, + } +} + /** * The connector row a successful sync writes. * @@ -1085,11 +1373,12 @@ export function buildSyncSuccessUpdate( now: Date, actualDocCount: number, nextSyncAt: Date | null, - holdNotice: string | null + holdNotice: string | null, + advanceLastSyncAt = true ) { return { status: 'active' as const, - lastSyncAt: now, + ...(advanceLastSyncAt ? { lastSyncAt: now } : {}), lastSyncError: holdNotice, lastSyncDocCount: actualDocCount, nextSyncAt, @@ -1238,8 +1527,8 @@ export function capReconciliationDeletions( /** * Reconstructs the previous completed sync's listing from its log counters. * - * No schema change is needed: every document the previous run listed landed in - * exactly one of added/updated/unchanged/failed, and `lastSyncDocCount` records + * Every document the previous run listed landed in exactly one of + * added/updated/unchanged/skipped/failed, and `lastSyncDocCount` records * how many documents the connector owned when that run finished. Documents the * user excluded also land in `docsUnchanged`, which can only inflate the * reconstructed listing — erring toward "the previous listing looked healthy", @@ -1256,6 +1545,7 @@ async function loadPreviousListingObservation( docsAdded: knowledgeConnectorSyncLog.docsAdded, docsUpdated: knowledgeConnectorSyncLog.docsUpdated, docsUnchanged: knowledgeConnectorSyncLog.docsUnchanged, + docsSkipped: knowledgeConnectorSyncLog.docsSkipped, docsFailed: knowledgeConnectorSyncLog.docsFailed, }) .from(knowledgeConnectorSyncLog) @@ -1274,7 +1564,11 @@ async function loadPreviousListingObservation( return { listedCount: - previous.docsAdded + previous.docsUpdated + previous.docsUnchanged + previous.docsFailed, + previous.docsAdded + + previous.docsUpdated + + previous.docsUnchanged + + previous.docsSkipped + + previous.docsFailed, ownedCount: previousOwnedCount, trustworthy, } @@ -1500,7 +1794,13 @@ export async function executeSync( docsUpdated: 0, docsDeleted: 0, docsUnchanged: 0, + docsSkipped: 0, docsFailed: 0, + processingDispatch: { + requested: 0, + accepted: 0, + failed: 0, + }, } const connectorRows = await db @@ -1517,7 +1817,7 @@ export async function executeSync( if (connectorRows.length === 0) { logger.warn(`Skipping sync: connector ${connectorId} not found, archived, or deleted`) - return { ...result, error: 'connector_unavailable' } + return { ...result, skipReason: 'connector_unavailable' } } const connectorBeforeLock = connectorRows[0] @@ -1564,7 +1864,7 @@ export async function executeSync( updatedAt: new Date(), }) .where(eq(knowledgeConnector.id, connectorId)) - return { ...result, error: 'knowledge_base_deleted' } + return { ...result, skipReason: 'knowledge_base_deleted' } } const userId = kbRows[0].userId @@ -1643,18 +1943,16 @@ export async function executeSync( connectorId, status: current.status, }) - return { ...result, error: 'connector_not_syncable' } + return { ...result, skipReason: 'connector_not_syncable' } } if (options.dispatchToken && current?.syncLockToken !== options.dispatchToken) { logger.info('Sync superseded by a newer dispatch, skipping', { connectorId }) - return { ...result, error: 'dispatch_superseded' } + return { ...result, skipReason: 'dispatch_superseded' } } logger.info('Sync already in progress, skipping', { connectorId }) - // Reported as an error so the task wrapper's `success: !result.error` does not - // present a skipped run as a successful zero-document sync. - return { ...result, error: 'sync_in_progress' } + return { ...result, skipReason: 'sync_in_progress' } } /** @@ -1716,6 +2014,7 @@ export async function executeSync( let accessToken = await resolveAccessToken(connector, connectorConfig, credentialUserId) const externalDocs: ExternalDocument[] = [] + let retainedSourcePayloadBytes = 0 let cursor: string | undefined let hasMore = true const syncContext: Record = { syncRunId: generateId() } @@ -1807,6 +2106,16 @@ export async function executeSync( syncContext, lastSyncAt ) + if (page.reconciliationSafe === false) { + syncContext.reconciliationUnsafe = true + } + if (!sourcePageFitsSyncWorkingSet(externalDocs.length, page.documents.length)) { + throw new ConnectorSyncWorkingSetLimitError(connectorId, 'source listing') + } + retainedSourcePayloadBytes = addSourcePagePayloadBytes( + retainedSourcePayloadBytes, + page.documents + ) externalDocs.push(...page.documents) if (page.hasMore && !page.nextCursor) { @@ -1842,70 +2151,100 @@ export async function executeSync( connectorId, }) - const [existingDocs, tombstonedDocs, excludedDocs] = await Promise.all([ - db - .select({ - id: document.id, - externalId: document.externalId, - contentHash: document.contentHash, - // Projected as well as filtered: the SQL predicate and the in-memory - // guard in partitionSyncReconciliation must both hold, so dropping - // either one alone cannot make an excluded document deletable. - userExcluded: document.userExcluded, - }) - .from(document) - .where( - and( - eq(document.connectorId, connectorId), - // A user's explicit "keep but don't index" choice must never make a - // document eligible for reconciliation deletion: it is deliberately - // never refreshed, so its absence from a listing says nothing. - eq(document.userExcluded, false), - isNull(document.archivedAt), - isNull(document.deletedAt) - ) - ), - // Docs already marked pending-removal by a prior sync's reconciliation (see - // shouldReconcileDeletions below): absent from the source once, not yet - // absent twice in a row. Included in classification so a document that - // reappears is recognized as existing (resurrected) rather than re-added - // as a duplicate. - db - .select({ - id: document.id, - externalId: document.externalId, - contentHash: document.contentHash, - deletedAt: document.deletedAt, - // Gates hard deletion in partitionSyncReconciliation without gating - // resurrection — see that function's contract. - userExcluded: document.userExcluded, - }) - .from(document) - .where( - and( - eq(document.connectorId, connectorId), - isNull(document.archivedAt), - isNotNull(document.deletedAt) - ) - ), - // Not filtered on deletedAt: a document can be both userExcluded and - // tombstoned (e.g. excluded via a bulk request that raced a sync marking - // it pending-removal). Excluding it here regardless of tombstone state - // keeps it short-circuited in the classification loop below instead of - // silently reappearing through the normal update/resurrect path. - db - .select({ externalId: document.externalId }) - .from(document) - .where( - and( - eq(document.connectorId, connectorId), - eq(document.userExcluded, true), - isNull(document.archivedAt) - ) - ), - ]) + /** + * Loaded sequentially with a shared sentinel budget. Three concurrent + * `SELECT`s each capped independently could still materialize three times + * the intended working set before the overflow was detected. + */ + const existingDocs = await db + .select({ + id: document.id, + externalId: document.externalId, + contentHash: document.contentHash, + storageKey: document.storageKey, + /** + * Projected as well as filtered: the SQL predicate and the in-memory guard in + * partitionSyncReconciliation must both hold, so dropping either one alone cannot make + * an excluded document deletable. + */ + userExcluded: document.userExcluded, + }) + .from(document) + .where( + and( + eq(document.connectorId, connectorId), + /** + * A user's explicit "keep but don't index" choice must never make a document eligible + * for reconciliation deletion: it is deliberately never refreshed, so its absence from + * a listing says nothing. + */ + eq(document.userExcluded, false), + isNull(document.archivedAt), + isNull(document.deletedAt) + ) + ) + .limit(syncWorkingSetQueryLimit(0)) + assertSyncWorkingSetWithinLimit(connectorId, 0, existingDocs.length) + + /** + * Documents already marked pending-removal by a prior sync's reconciliation: absent from the + * source once, not yet absent twice in a row. Including them in classification lets a document + * that reappears be recognized as existing (resurrected) rather than re-added. + */ + const tombstonedDocs = await db + .select({ + id: document.id, + externalId: document.externalId, + contentHash: document.contentHash, + storageKey: document.storageKey, + deletedAt: document.deletedAt, + /** + * Gates hard deletion in partitionSyncReconciliation without gating resurrection. + */ + userExcluded: document.userExcluded, + }) + .from(document) + .where( + and( + eq(document.connectorId, connectorId), + /** + * Load both included and user-excluded tombstones. Excluded tombstones are never + * deletion-eligible, but they must remain resurrection-eligible when their source + * document reappears or the row becomes permanently invisible and unrestorable. + */ + isNull(document.archivedAt), + isNotNull(document.deletedAt) + ) + ) + .limit(syncWorkingSetQueryLimit(existingDocs.length)) + assertSyncWorkingSetWithinLimit(connectorId, existingDocs.length, tombstonedDocs.length) - const excludedExternalIds = new Set(excludedDocs.map((d) => d.externalId).filter(Boolean)) + /** + * Live user-excluded rows form the third disjoint population in the shared memory budget. + * User-excluded tombstones were loaded above so source presence can clear their deletion marker; + * they are added to `excludedExternalIds` below to keep hydration short-circuited. + */ + const loadedOwnedDocs = existingDocs.length + tombstonedDocs.length + const excludedDocs = await db + .select({ externalId: document.externalId }) + .from(document) + .where( + and( + eq(document.connectorId, connectorId), + eq(document.userExcluded, true), + isNull(document.archivedAt), + isNull(document.deletedAt) + ) + ) + .limit(syncWorkingSetQueryLimit(loadedOwnedDocs)) + assertSyncWorkingSetWithinLimit(connectorId, loadedOwnedDocs, excludedDocs.length) + + const excludedExternalIds = new Set( + [ + ...excludedDocs.map((doc) => doc.externalId), + ...tombstonedDocs.filter((doc) => doc.userExcluded).map((doc) => doc.externalId), + ].filter((externalId): externalId is string => Boolean(externalId)) + ) const priorByExternalId = new Map( [...existingDocs, ...tombstonedDocs] @@ -1944,13 +2283,19 @@ export async function executeSync( switch (classification.type) { case 'skip': - pendingOps.push({ type: 'skip', extDoc }) + pendingOps.push({ + type: 'skip', + existingId: classification.existingId, + extDoc, + }) break case 'drop': // Empty, non-deferred content is never usable. If this was a // reappearing tombstoned document, its content was never verified as // current — see failedExternalIds below. - if (existing) failedExternalIds.add(extDoc.externalId) + if (existing) { + recordUnverifiedExistingRefresh(result, failedExternalIds, extDoc.externalId) + } logger.info(`Skipping empty document: ${extDoc.title}`, { externalId: extDoc.externalId, }) @@ -1967,8 +2312,11 @@ export async function executeSync( // verified-unchanged match — same as the deferred-hydration // equivalent above. A genuine hash match never sets skippedReason, // so this only fires for the short-circuited case. - if (extDoc.skippedReason && existing) failedExternalIds.add(extDoc.externalId) - result.docsUnchanged++ + if (extDoc.skippedReason && existing) { + recordUnverifiedExistingRefresh(result, failedExternalIds, extDoc.externalId) + } else { + result.docsUnchanged++ + } break } } @@ -1992,9 +2340,12 @@ export async function executeSync( // Oversized/skipped docs become visible `failed` rows (never silent). They are // flagged either at listing time (skip ops here) or discovered only at fetch // time during hydration below; both are collected and persisted after hydration. - const skipExtDocs: ExternalDocument[] = rawBatch - .filter((op) => op.type === 'skip') - .map((op) => op.extDoc) + const skipOps = rawBatch.filter((op) => op.type === 'skip') + const skippedRetryHashUpdates: Array<{ + existingId: string + externalId: string + contentHash: string + }> = [] const contentOps = rawBatch.filter((op) => op.type !== 'skip') const deferredOps = contentOps.filter((op) => op.extDoc.contentDeferred) @@ -2007,39 +2358,49 @@ export async function executeSync( const hydrated = await Promise.allSettled( deferredOps.map(async (op) => { - const fullDoc = await connectorConfig.getDocument( - accessToken!, - sourceConfig, - op.extDoc.externalId, - syncContext + const fullDoc = requireHydratedListedDocument( + await connectorConfig.getDocument( + accessToken!, + sourceConfig, + op.extDoc.externalId, + syncContext + ), + op.extDoc.externalId ) // A connector may only learn a file is too large at fetch time (its // listing has no size). Surface that as a failed row for new files; keep // already-indexed files as last-known-good rather than downgrading them. if (fullDoc?.skippedReason) { if (op.type === 'add') { - skipExtDocs.push({ - ...op.extDoc, - skippedReason: fullDoc.skippedReason, - contentHash: fullDoc.contentHash ?? op.extDoc.contentHash, - metadata: { ...op.extDoc.metadata, ...fullDoc.metadata }, + skipOps.push({ + type: 'skip', + extDoc: mergeHydratedSkippedDocument(op.extDoc, fullDoc), }) } else if (op.type === 'update') { - // Already-indexed file is kept as last-known-good (not downgraded), so it - // counts as unchanged rather than slipping past every result counter. Not a - // verified refresh, though — see failedExternalIds below. - result.docsUnchanged++ - failedExternalIds.add(op.extDoc.externalId) + if (fullDoc.skippedExistingDisposition === 'replace') { + skipOps.push({ + type: 'skip', + existingId: op.existingId, + extDoc: mergeHydratedSkippedDocument(op.extDoc, fullDoc), + }) + } else { + if (fullDoc.skippedRetryContentHash) { + skippedRetryHashUpdates.push({ + existingId: op.existingId, + externalId: op.extDoc.externalId, + contentHash: fullDoc.skippedRetryContentHash, + }) + } + /** Preserve last-known-good content and replay the unverified source change. */ + recordUnverifiedExistingRefresh(result, failedExternalIds, op.extDoc.externalId) + } } return null } - if (!fullDoc || !hasIndexablePayload(fullDoc)) { - // An empty re-fetch leaves an already-indexed update as last-known-good; count - // it as unchanged so the totals still reconcile with documents seen. Not a - // verified refresh, though — see failedExternalIds below. + if (!hasIndexablePayload(fullDoc)) { + /** An empty refresh cannot replace or advance past last-known-good content. */ if (op.type === 'update') { - result.docsUnchanged++ - failedExternalIds.add(op.extDoc.externalId) + recordUnverifiedExistingRefresh(result, failedExternalIds, op.extDoc.externalId) } return null } @@ -2078,22 +2439,52 @@ export async function executeSync( } } + if (skippedRetryHashUpdates.length > 0) { + try { + const missedExternalIds = await persistSkippedRetryHashes( + connector.knowledgeBaseId, + connectorId, + skippedRetryHashUpdates + ) + if (missedExternalIds.length > 0) { + logger.warn('Skipped retry hashes were not persisted for detached documents', { + connectorId, + externalIds: missedExternalIds, + }) + } + } catch (error) { + logger.error('Failed to persist skipped document retry hashes', { + connectorId, + count: skippedRetryHashUpdates.length, + error: toError(error).message, + }) + throw error + } + } + // Record all skipped (oversized) docs in this batch in one bulk insert. - if (skipExtDocs.length > 0) { + if (skipOps.length > 0) { try { - const recorded = await skipDocuments( + const recorded = await persistSkippedDocuments( connector.knowledgeBaseId, connectorId, connector.connectorType, - skipExtDocs, + skipOps, sourceConfig ) - result.docsFailed += recorded + result.docsSkipped += recorded } catch (error) { - result.docsFailed += skipExtDocs.length + /** + * The source items were intentionally skipped, but failing to persist their visible + * failed rows is an actual sync failure. + */ + result.docsFailed += skipOps.length + for (const op of skipOps) { + failedExternalIds.add(op.extDoc.externalId) + } logger.error('Failed to record skipped documents', { connectorId, - count: skipExtDocs.length, + count: skipOps.length, error: toError(error).message, }) } @@ -2144,15 +2535,19 @@ export async function executeSync( } if (batchDocs.length > 0) { + result.processingDispatch.requested += batchDocs.length try { - await processDocumentsWithQueue( + const dispatch = await processDocumentsWithQueue( batchDocs, connector.knowledgeBaseId, {}, generateId(), billingAttribution ) + result.processingDispatch.accepted += dispatch.accepted + result.processingDispatch.failed += dispatch.failed } catch (error) { + result.processingDispatch.failed += batchDocs.length logger.warn('Failed to enqueue batch for processing — will retry on next sync', { connectorId, count: batchDocs.length, @@ -2285,14 +2680,6 @@ export async function executeSync( let safeSoftDeleteIds: string[] = [] let safeHardDeleteIds: string[] = [] - /** - * Probes ownership before the reconciliation writes rather than after them: - * the soft-delete transaction and `hardDeleteDocuments` below are the most - * destructive block in this file, and a run that has lost its lock must not - * execute them alongside its replacement. - */ - await beatIfDue() - if (candidateIds.length > 0) { /** * A concurrent "delete connector, keep documents" request detaches these @@ -2305,16 +2692,34 @@ export async function executeSync( * already billed) as a standalone KB entry. */ await db.transaction(async (tx) => { - await tx.execute( - sql`SELECT 1 FROM knowledge_connector WHERE id = ${connectorId} FOR UPDATE` - ) + const [activeKnowledgeBase] = await tx + .select({ id: knowledgeBase.id }) + .from(knowledgeBase) + .where( + and(eq(knowledgeBase.id, connector.knowledgeBaseId), isNull(knowledgeBase.deletedAt)) + ) + .for('update') + if (!activeKnowledgeBase) throw new SyncLockLostException(connectorId) + + const [heldSyncLock] = await tx + .select({ id: knowledgeConnector.id }) + .from(knowledgeConnector) + .where(stillHoldsSyncLock(connectorId, syncLogId)) + .for('update') + if (!heldSyncLock) throw new SyncLockLostException(connectorId) const stillOwned = new Set( ( await tx .select({ id: document.id }) .from(document) - .where(and(inArray(document.id, candidateIds), eq(document.connectorId, connectorId))) + .where( + and( + inArray(document.id, candidateIds), + eq(document.connectorId, connectorId), + isNull(document.archivedAt) + ) + ) ).map((d) => d.id) ) @@ -2338,13 +2743,28 @@ export async function executeSync( await tx .update(document) .set({ deletedAt: null }) - .where(inArray(document.id, safeResurrectIds)) + .where( + and( + inArray(document.id, safeResurrectIds), + eq(document.connectorId, connectorId), + isNull(document.archivedAt), + isNotNull(document.deletedAt) + ) + ) } if (safeSoftDeleteIds.length > 0) { await tx .update(document) .set({ deletedAt: new Date() }) - .where(inArray(document.id, safeSoftDeleteIds)) + .where( + and( + inArray(document.id, safeSoftDeleteIds), + eq(document.connectorId, connectorId), + eq(document.userExcluded, false), + isNull(document.archivedAt), + isNull(document.deletedAt) + ) + ) } }) } @@ -2365,16 +2785,24 @@ export async function executeSync( } for (let i = 0; i < safeHardDeleteIds.length; i += HARD_DELETE_CHUNK_SIZE) { await beatIfDue() - - // Re-verifies connectorId once more at the moment of the actual delete - // query — the FOR UPDATE lock above only covers the window up to its - // own commit; this closes the remaining gap between that commit and - // this call. - result.docsDeleted += await hardDeleteDocuments( - safeHardDeleteIds.slice(i, i + HARD_DELETE_CHUNK_SIZE), - syncLogId, - connectorId - ) + try { + result.docsDeleted += await hardDeleteDocuments( + safeHardDeleteIds.slice(i, i + HARD_DELETE_CHUNK_SIZE), + syncLogId, + connectorId, + connector.knowledgeBaseId, + { + connectorId, + knowledgeBaseId: connector.knowledgeBaseId, + syncLockToken: syncLogId, + } + ) + } catch (error) { + if (error instanceof ConnectorSyncDeletionGuardError) { + throw new SyncLockLostException(connectorId) + } + throw error + } } const postBatchPresence = await checkSyncTargetPresence(connectorId, connector.knowledgeBaseId) @@ -2389,15 +2817,17 @@ export async function executeSync( * Reclaims documents this connector left unfinished: a terminated attempt, a * dispatch that never produced a run, or a run abandoned mid-processing. * - * The query narrows to this connector's non-terminal documents inside the - * `RETRY_WINDOW_DAYS` window and excludes anything created by this sync; - * {@link isStuckDocumentSweepEligible} then makes the per-document decision, - * so the age rules live in one place rather than being split between SQL and - * TypeScript. Skipped (oversized) documents are recorded as content-less - * `failed` rows with no storage key and can never be reprocessed, so they are - * excluded outright. + * The query applies each status's age rule before the candidate limit, so + * recently requeued old uploads cannot hide genuinely overdue work. The same + * rules are evaluated again after candidate rows are locked below. Skipped + * documents are content-less `failed` rows with no storage key and therefore + * remain excluded outright. */ const sweepEvaluatedAt = new Date() + const queuedGraceCutoff = new Date(sweepEvaluatedAt.getTime() - QUEUED_DISPATCH_GRACE_MS) + const processingStaleCutoff = new Date( + sweepEvaluatedAt.getTime() - STALE_PROCESSING_MINUTES * 60 * 1000 + ) const sweepCandidates = await db .select({ id: document.id, @@ -2408,6 +2838,7 @@ export async function executeSync( processingStatus: document.processingStatus, processingQueuedAt: document.processingQueuedAt, processingStartedAt: document.processingStartedAt, + processingDeferredUntil: document.processingDeferredUntil, processingCompletedAt: document.processingCompletedAt, uploadedAt: document.uploadedAt, }) @@ -2416,6 +2847,32 @@ export async function executeSync( and( eq(document.connectorId, connectorId), inArray(document.processingStatus, SWEEPABLE_PROCESSING_STATUSES), + or( + and( + eq(document.processingStatus, 'failed'), + sql`COALESCE(${document.processingCompletedAt}, ${document.processingQueuedAt}, ${document.uploadedAt}) < ${sql.param(queuedGraceCutoff, document.processingCompletedAt)}` + ), + and( + eq(document.processingStatus, 'pending'), + or( + and( + isNotNull(document.processingDeferredUntil), + lt(document.processingDeferredUntil, queuedGraceCutoff) + ), + and( + isNull(document.processingDeferredUntil), + sql`COALESCE(${document.processingQueuedAt}, ${document.uploadedAt}) < ${sql.param(queuedGraceCutoff, document.processingQueuedAt)}` + ) + ) + ), + and( + eq(document.processingStatus, 'processing'), + or( + isNull(document.processingStartedAt), + lt(document.processingStartedAt, processingStaleCutoff) + ) + ) + ), // Dead letters are left alone: past the budget, re-dispatching only // re-bills a document that has failed the same way every time. lt(document.processingAttempts, MAX_PROCESSING_ATTEMPTS), @@ -2427,18 +2884,21 @@ export async function executeSync( isNull(document.deletedAt) ) ) - /** - * Oldest first, so the most overdue documents drain before newer ones and - * the bound below can never starve a document indefinitely. Without an - * order the limit would take an arbitrary subset each sync. - */ - .orderBy(asc(document.uploadedAt)) + .orderBy( + asc(sql`CASE + WHEN ${document.processingStatus} = 'failed' + THEN COALESCE(${document.processingCompletedAt}, ${document.processingQueuedAt}, ${document.uploadedAt}) + WHEN ${document.processingStatus} = 'pending' + THEN COALESCE(${document.processingDeferredUntil}, ${document.processingQueuedAt}, ${document.uploadedAt}) + ELSE COALESCE(${document.processingStartedAt}, ${sql.param(new Date(0), document.processingStartedAt)}) + END`), + asc(document.id) + ) .limit(STUCK_RETRY_MAX_CANDIDATES_PER_SYNC) - const stuckDocs = sweepCandidates - .filter((row): row is typeof row & { processingStatus: DocumentProcessingStatus } => + const stuckDocs = sweepCandidates.filter( + (row): row is typeof row & { processingStatus: DocumentProcessingStatus } => isDocumentProcessingStatus(row.processingStatus) - ) - .filter((doc) => isStuckDocumentSweepEligible(doc, sweepEvaluatedAt)) + ) if (stuckDocs.length > 0) { logger.info(`Retrying ${stuckDocs.length} stuck documents`, { connectorId }) @@ -2447,32 +2907,65 @@ export async function executeSync( let retryDocs: typeof stuckDocs = [] /** - * Takes the same `knowledge_connector` FOR UPDATE lock the DELETE route - * takes before nulling connectorId on detached documents, so the two - * requests serialize instead of racing — a plain re-SELECT only - * narrows the window between the ownership check and these writes, it - * never closes it, since a concurrent detach can still commit in - * between. Embedding cleanup and the processing-state reset happen - * inside the same locked transaction so a document already claimed by - * a detach never gets its embeddings wiped or is reprocessed as if - * still connector-owned. + * Locks the parent first to match lifecycle mutations, then proves this + * run still owns the live connector row. A bare connector lock can match + * a replacement run after this lease was reclaimed, allowing the stale + * run to reset documents and dispatch duplicate processing. */ await db.transaction(async (tx) => { - await tx.execute( - sql`SELECT 1 FROM knowledge_connector WHERE id = ${connectorId} FOR UPDATE` - ) - - const stillOwnedIds = new Set( - ( - await tx - .select({ id: document.id }) - .from(document) - .where( - and(inArray(document.id, stuckDocIds), eq(document.connectorId, connectorId)) - ) - ).map((d) => d.id) + const [activeKnowledgeBase] = await tx + .select({ id: knowledgeBase.id }) + .from(knowledgeBase) + .where( + and(eq(knowledgeBase.id, connector.knowledgeBaseId), isNull(knowledgeBase.deletedAt)) + ) + .for('update') + if (!activeKnowledgeBase) throw new SyncLockLostException(connectorId) + + const [heldSyncLock] = await tx + .select({ id: knowledgeConnector.id }) + .from(knowledgeConnector) + .where(stillHoldsSyncLock(connectorId, syncLogId)) + .for('update') + if (!heldSyncLock) throw new SyncLockLostException(connectorId) + + const lockedCandidates = await tx + .select({ + id: document.id, + fileUrl: document.fileUrl, + filename: document.filename, + fileSize: document.fileSize, + mimeType: document.mimeType, + processingStatus: document.processingStatus, + processingQueuedAt: document.processingQueuedAt, + processingStartedAt: document.processingStartedAt, + processingDeferredUntil: document.processingDeferredUntil, + processingCompletedAt: document.processingCompletedAt, + uploadedAt: document.uploadedAt, + }) + .from(document) + .where( + and( + inArray(document.id, stuckDocIds), + eq(document.connectorId, connectorId), + inArray(document.processingStatus, SWEEPABLE_PROCESSING_STATUSES), + lt(document.processingAttempts, MAX_PROCESSING_ATTEMPTS), + eq(document.userExcluded, false), + isNotNull(document.storageKey), + isNull(document.archivedAt), + isNull(document.deletedAt) + ) + ) + .orderBy(asc(document.id)) + .for('update') + + retryDocs = selectStuckDocumentSweepCandidates( + lockedCandidates.filter( + (row): row is typeof row & { processingStatus: DocumentProcessingStatus } => + isDocumentProcessingStatus(row.processingStatus) + ), + sweepEvaluatedAt ) - retryDocs = stuckDocs.filter((doc) => stillOwnedIds.has(doc.id)) if (retryDocs.length > 0) { const retryDocIds = retryDocs.map((doc) => doc.id) @@ -2481,11 +2974,15 @@ export async function executeSync( .update(document) .set({ processingStatus: 'pending', - // `processingQueuedAt` is not stamped here: the dispatch below - // funnels through `markDocumentsQueued`, which stamps it for - // every caller. Setting it here wrote a value that was - // immediately overwritten. + /** + * Invalidates the prior dispatch generation in the same write + * that reopens the row. The dispatch below installs its fresh + * generation through `markDocumentsQueued`. + */ + processingQueuedAt: null, + processingQueueToken: null, processingStartedAt: null, + processingDeferredUntil: null, processingCompletedAt: null, processingError: null, chunkCount: 0, @@ -2493,19 +2990,21 @@ export async function executeSync( characterCount: 0, }) /** - * Re-asserts the status the candidate SELECT filtered on. - * - * The ownership re-check above covers `connectorId` only, so - * between the SELECT and this write a worker could have claimed - * or finished the document. Resetting it then would delete the - * embeddings of a pass that had already completed and bill a - * second one — the same TOCTOU the connector-side writes in this - * file were guarded against. + * These rows were freshly revalidated and locked above. The + * lifecycle predicates remain as defence in depth; the row locks + * ensure no retry can install a newer queue generation between + * that eligibility decision and this reset. */ .where( and( inArray(document.id, retryDocIds), - inArray(document.processingStatus, SWEEPABLE_PROCESSING_STATUSES) + eq(document.connectorId, connectorId), + inArray(document.processingStatus, SWEEPABLE_PROCESSING_STATUSES), + lt(document.processingAttempts, MAX_PROCESSING_ATTEMPTS), + eq(document.userExcluded, false), + isNotNull(document.storageKey), + isNull(document.archivedAt), + isNull(document.deletedAt) ) ) .returning({ id: document.id }) @@ -2523,10 +3022,15 @@ export async function executeSync( }) for (let i = 0; i < retryDocs.length; i += STUCK_RETRY_DISPATCH_CHUNK_SIZE) { - await beatIfDue() + if (!(await heartbeatLiveSyncLock(connectorId, syncLogId))) { + throw new SyncLockLostException(connectorId) + } + lastHeartbeatAtMs = Date.now() - await processDocumentsWithQueue( - retryDocs.slice(i, i + STUCK_RETRY_DISPATCH_CHUNK_SIZE).map((doc) => ({ + const retryChunk = retryDocs.slice(i, i + STUCK_RETRY_DISPATCH_CHUNK_SIZE) + result.processingDispatch.requested += retryChunk.length + const dispatch = await processDocumentsWithQueue( + retryChunk.map((doc) => ({ documentId: doc.id, filename: doc.filename ?? 'document.txt', fileUrl: doc.fileUrl ?? '', @@ -2538,13 +3042,15 @@ export async function executeSync( generateId(), billingAttribution ) + result.processingDispatch.accepted += dispatch.accepted + result.processingDispatch.failed += dispatch.failed } } catch (error) { /** * Kept out of the best-effort swallow below. A run that has provably * lost its lock would otherwise be mislabelled an enqueue failure, fall - * through, and publish `completeSyncLog(..., 'completed')` — which the - * replacement run then reads as corroboration of its own listing. + * through and publish an atomic completed outcome, which a replacement + * run could then read as corroboration of its own listing. */ if (error instanceof SyncLockLostException) throw error @@ -2553,54 +3059,23 @@ export async function executeSync( count: stuckDocs.length, error: toError(error).message, }) + result.processingDispatch.failed += + result.processingDispatch.requested - + result.processingDispatch.accepted - + result.processingDispatch.failed } } - const logClosed = await completeSyncLog(syncLogId, 'completed', result, { - requireSyncLockOn: connectorId, - }) - - /** - * Short-circuits on exactly the condition {@link writeTerminalConnectorState} - * would have rejected two statements later, so the outcome is unchanged and - * the intervening document count is skipped. Returning here is what keeps a - * discarded run from publishing the `completed` row that - * {@link loadPreviousListingObservation} reads as corroboration. - */ - if (!logClosed) { - logger.warn('Sync result discarded — connector was reclaimed while this run was executing', { - connectorId, - syncLogId, - ...result, - }) - return markSyncSuperseded(result) - } - - const [{ count: actualDocCount }] = await db - .select({ count: sql`count(*)::int` }) - .from(document) - .where( - and( - eq(document.connectorId, connectorId), - eq(document.userExcluded, false), - isNull(document.archivedAt), - isNull(document.deletedAt) - ) - ) - - const now = new Date() - const successWriteLanded = await writeTerminalConnectorState( + const completionLanded = await completeSuccessfulSync( connectorId, + connector.knowledgeBaseId, syncLogId, - buildSyncSuccessUpdate( - now, - actualDocCount, - calculateNextSyncTime(connector.syncIntervalMinutes), - reconciliationHoldNotice - ) + connector.syncIntervalMinutes, + result, + reconciliationHoldNotice ) - if (!successWriteLanded) { + if (!completionLanded) { logger.warn('Sync result discarded — connector was reclaimed while this run was executing', { connectorId, syncLogId, @@ -2632,18 +3107,35 @@ export async function executeSync( try { await releaseSyncLockOnDeletedConnector(connectorId, syncLogId) - // Includes pending-removal (tombstoned) docs — the connector is gone, so - // there's no future sync left to confirm or resurrect them. - const connectorDocs = await db - .select({ id: document.id }) - .from(document) - .where(and(eq(document.connectorId, connectorId), isNull(document.archivedAt))) - - await hardDeleteDocuments( - connectorDocs.map((doc) => doc.id), - syncLogId, - connectorId - ) + /** + * Includes pending-removal tombstones. Page IDs so deleting a connector + * with a legacy corpus above the sync admission cap cannot materialize + * the entire corpus in the cleanup worker. + */ + let afterDocumentId: string | undefined + while (true) { + const connectorDocs = await db + .select({ id: document.id }) + .from(document) + .where( + and( + eq(document.connectorId, connectorId), + isNull(document.archivedAt), + afterDocumentId ? gt(document.id, afterDocumentId) : undefined + ) + ) + .orderBy(asc(document.id)) + .limit(CONNECTOR_DELETION_CLEANUP_BATCH_SIZE) + if (connectorDocs.length === 0) break + + await hardDeleteDocuments( + connectorDocs.map((doc) => doc.id), + syncLogId, + connectorId + ) + afterDocumentId = connectorDocs.at(-1)?.id + if (connectorDocs.length < CONNECTOR_DELETION_CLEANUP_BATCH_SIZE) break + } await completeSyncLog(syncLogId, 'failed', result, { errorMessage: 'Connector deleted during sync', @@ -2655,7 +3147,7 @@ export async function executeSync( }) } - result.error = 'Connector deleted during sync' + result.skipReason = 'connector_deleted_during_sync' return result } @@ -2665,11 +3157,10 @@ export async function executeSync( try { await completeSyncLog(syncLogId, 'failed', result, { errorMessage }) - const failureUpdate = buildSyncFailureUpdate( - new Date(), - connector.consecutiveFailures, - errorMessage - ) + const failureUpdate = + error instanceof ConnectorSyncCapacityError + ? buildSyncCapacityUpdate(new Date(), connector.consecutiveFailures, errorMessage) + : buildSyncFailureUpdate(new Date(), connector.consecutiveFailures, errorMessage) if (failureUpdate.status === 'disabled') { logger.warn('Connector disabled after repeated failures', { @@ -2686,10 +3177,9 @@ export async function executeSync( /** * Deliberately does NOT get {@link markSyncSuperseded}. `result.error` - * is set to the real failure cause below and the task wrapper already - * reports this run as unsuccessful, so overwriting it with - * `sync_superseded` would destroy the diagnostic without changing the - * reported outcome. The supersession is carried by this log line instead. + * is set to the real failure cause below, so replacing it with lifecycle + * control flow would destroy the diagnostic. The supersession is carried + * by this log line instead. */ if (!failureWriteLanded) { logger.warn( @@ -2768,30 +3258,40 @@ function buildSkippedDocumentRow( } /** - * Records source files that were intentionally not indexed (e.g. they exceed the - * connector's size limit) as content-less `failed` documents in a single bulk insert. + * Records source files that were intentionally not indexed as content-less `failed` + * documents. New rows are inserted in bulk; authoritative skips replace stale rows. * This keeps the files visible in the knowledge base UI — with `processingError` * explaining why — instead of silently dropping them. The rows have no storage key, * so they are excluded from the stuck-document retry sweep (nothing to reprocess). * - * Only called for files not already indexed; previously-indexed files that later - * exceed the limit are kept as-is (last-known-good) by `classifyExternalDoc`. + * Ordinary skips on previously indexed files remain last-known-good. A connector can + * explicitly make a skip authoritative when retaining stale content would be wrong. * * Returns the number of rows recorded. */ -async function skipDocuments( +export async function persistSkippedDocuments( knowledgeBaseId: string, connectorId: string, connectorType: string, - extDocs: ExternalDocument[], + skipOps: Array<{ + type: 'skip' + existingId?: string + extDoc: ExternalDocument + }>, sourceConfig?: Record ): Promise { - if (extDocs.length === 0) { + if (skipOps.length === 0) { return 0 } - const rows = extDocs.map((extDoc) => - buildSkippedDocumentRow(knowledgeBaseId, connectorId, connectorType, extDoc, sourceConfig) + const inserts = skipOps + .filter((op) => !op.existingId) + .map((op) => + buildSkippedDocumentRow(knowledgeBaseId, connectorId, connectorType, op.extDoc, sourceConfig) + ) + const replacements = skipOps.filter((op): op is typeof op & { existingId: string } => + Boolean(op.existingId) ) + const replacedFileUrls: string[] = [] await db.transaction(async (tx) => { const isActive = await isKnowledgeBaseActiveInTx(tx, knowledgeBaseId) @@ -2799,10 +3299,115 @@ async function skipDocuments( throw new Error(`Knowledge base ${knowledgeBaseId} is deleted`) } - await tx.insert(document).values(rows) + if (inserts.length > 0) { + await tx.insert(document).values(inserts) + } + + for (const replacement of replacements) { + const skipped = buildSkippedDocumentRow( + knowledgeBaseId, + connectorId, + connectorType, + replacement.extDoc, + sourceConfig + ) + const [current] = await tx + .select({ fileUrl: document.fileUrl }) + .from(document) + .where(connectorDocumentSyncTarget(replacement.existingId, knowledgeBaseId, connectorId)) + .for('update') + if (!current) { + throw new Error(`Document ${replacement.existingId} is no longer active`) + } + const tagValues = replacement.extDoc.metadata + ? resolveTagMapping(connectorType, replacement.extDoc.metadata, sourceConfig) + : undefined + const replaced = await tx + .update(document) + .set({ + filename: skipped.filename, + fileUrl: skipped.fileUrl, + storageKey: skipped.storageKey, + fileSize: skipped.fileSize, + mimeType: skipped.mimeType, + processingStatus: skipped.processingStatus, + processingError: skipped.processingError, + processingStartedAt: null, + processingDeferredUntil: null, + processingCompletedAt: new Date(), + processingQueuedAt: null, + processingQueueToken: null, + processingAttempts: 0, + chunkCount: 0, + tokenCount: 0, + characterCount: 0, + contentHash: skipped.contentHash, + sourceUrl: skipped.sourceUrl, + uploadedAt: skipped.uploadedAt, + deletedAt: null, + ...tagValues, + }) + .where(connectorDocumentSyncTarget(replacement.existingId, knowledgeBaseId, connectorId)) + .returning({ id: document.id }) + if (replaced.length === 0) { + throw new Error(`Document ${replacement.existingId} is no longer active`) + } + if (current.fileUrl) replacedFileUrls.push(current.fileUrl) + await tx.delete(embedding).where(eq(embedding.documentId, replacement.existingId)) + } }) - return rows.length + for (const fileUrl of replacedFileUrls) { + try { + const urlPath = new URL(fileUrl, 'http://localhost').pathname + const storageKey = extractStorageKey(urlPath) + if (storageKey && storageKey !== urlPath) { + await deleteFile({ key: storageKey, context: 'knowledge-base' }) + await deleteFileMetadata(storageKey) + } + } catch (error) { + logger.warn('Failed to delete storage for an authoritatively skipped document', { + error: toError(error).message, + }) + } + } + + return skipOps.length +} + +/** + * Persists only connector-owned retry hashes for skipped refreshes of existing + * documents. Indexed content and processing state stay last-known-good while the + * hash guarantees that unchanged listing metadata still re-enters hydration. + */ +export async function persistSkippedRetryHashes( + knowledgeBaseId: string, + connectorId: string, + updates: Array<{ existingId: string; externalId: string; contentHash: string }> +): Promise { + if (updates.length === 0) return [] + + const missedExternalIds: string[] = [] + + await db.transaction(async (tx) => { + const isActive = await isKnowledgeBaseActiveInTx(tx, knowledgeBaseId) + if (!isActive) { + throw new Error(`Knowledge base ${knowledgeBaseId} is deleted`) + } + + for (const update of updates) { + const persisted = await tx + .update(document) + .set({ contentHash: update.contentHash }) + .where(connectorDocumentSyncTarget(update.existingId, knowledgeBaseId, connectorId)) + .returning({ id: document.id }) + if (persisted.length === 0) { + missedExternalIds.push(update.externalId) + } + } + }) + + return missedExternalIds } /** @@ -2888,6 +3493,20 @@ async function addDocument( * Update an existing connector-sourced document with new content. * Updates in-place to avoid unique constraint violations on (connectorId, externalId). */ +export function connectorDocumentSyncTarget( + documentId: string, + knowledgeBaseId: string, + connectorId: string +) { + return and( + eq(document.id, documentId), + eq(document.knowledgeBaseId, knowledgeBaseId), + eq(document.connectorId, connectorId), + eq(document.userExcluded, false), + isNull(document.archivedAt) + ) +} + async function updateDocument( existingDocId: string, knowledgeBaseId: string, @@ -2900,9 +3519,11 @@ async function updateDocument( const existingRows = await db .select({ fileUrl: document.fileUrl }) .from(document) - .where(eq(document.id, existingDocId)) + .where(connectorDocumentSyncTarget(existingDocId, knowledgeBaseId, connectorId)) .limit(1) - const oldFileUrl = existingRows[0]?.fileUrl + const existingRow = existingRows[0] + if (!existingRow) throw new Error(`Document ${existingDocId} is no longer active`) + const oldFileUrl = existingRow.fileUrl const artifact = connectorStoredArtifact(extDoc) const customKey = `kb/${buildStorageKeySegment(`${Date.now()}-${existingDocId}-`, artifact.fileName)}` @@ -2945,6 +3566,15 @@ async function updateDocument( sourceUrl: extDoc.sourceUrl ?? null, ...tagValues, processingStatus: 'pending', + /** Prevents an older delayed worker from claiming newly stored content. */ + processingQueuedAt: null, + processingQueueToken: null, + processingDeferredUntil: null, + /** A new document version starts with a fresh unattended-retry budget. */ + processingAttempts: 0, + processingStartedAt: null, + processingCompletedAt: null, + processingError: null, uploadedAt: new Date(), // A tombstoned document reappearing with changed content is resurrected // in the same write as its content update — otherwise reconciliation's @@ -2952,17 +3582,7 @@ async function updateDocument( // on deletedAt IS NULL, rejects the row and leaves stale content active. deletedAt: null, }) - .where( - and( - eq(document.id, existingDocId), - // A concurrent "delete connector, keep documents" request can null out - // connectorId between this sync's liveness check and this write. Without - // this check, that now-standalone document would still match on id alone - // and get overwritten with connector-sourced content post-detachment. - eq(document.connectorId, connectorId), - isNull(document.archivedAt) - ) - ) + .where(connectorDocumentSyncTarget(existingDocId, knowledgeBaseId, connectorId)) .returning({ id: document.id }) .then((rows) => { if (rows.length === 0) { diff --git a/apps/sim/lib/knowledge/constants.ts b/apps/sim/lib/knowledge/constants.ts index df4434758e7..80c2f8b2da1 100644 --- a/apps/sim/lib/knowledge/constants.ts +++ b/apps/sim/lib/knowledge/constants.ts @@ -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 diff --git a/apps/sim/lib/knowledge/documents/document-indexing-usage.test.ts b/apps/sim/lib/knowledge/documents/document-indexing-usage.test.ts index d0dcf244ec5..ed540e42a0e 100644 --- a/apps/sim/lib/knowledge/documents/document-indexing-usage.test.ts +++ b/apps/sim/lib/knowledge/documents/document-indexing-usage.test.ts @@ -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' })), })) diff --git a/apps/sim/lib/knowledge/documents/document-processing-error.test.ts b/apps/sim/lib/knowledge/documents/document-processing-error.test.ts new file mode 100644 index 00000000000..011f9ee689e --- /dev/null +++ b/apps/sim/lib/knowledge/documents/document-processing-error.test.ts @@ -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'), + }) + }) +}) diff --git a/apps/sim/lib/knowledge/documents/document-processing-error.ts b/apps/sim/lib/knowledge/documents/document-processing-error.ts new file mode 100644 index 00000000000..351b71148b1 --- /dev/null +++ b/apps/sim/lib/knowledge/documents/document-processing-error.ts @@ -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 + 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 + + constructor( + code: Exclude, + 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 +} diff --git a/apps/sim/lib/knowledge/documents/document-processing-source.test.ts b/apps/sim/lib/knowledge/documents/document-processing-source.test.ts index 8b68aaf7883..23cbed90464 100644 --- a/apps/sim/lib/knowledge/documents/document-processing-source.test.ts +++ b/apps/sim/lib/knowledge/documents/document-processing-source.test.ts @@ -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 | undefined)?.processingStatus === status - ) - ).toBe(true) + const setIndex = dbChainMockFns.set.mock.calls.findIndex( + (call) => (call[0] as Record | undefined)?.processingStatus === status + ) + expect(setIndex).toBeGreaterThanOrEqual(0) - // `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 setOrder = dbChainMockFns.set.mock.invocationCallOrder[setIndex] + const whereIndex = dbChainMockFns.where.mock.invocationCallOrder.findIndex( + (whereOrder) => whereOrder > setOrder ) - expect(guard).toBeDefined() - return guard?.[0] + 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 | 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).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 | undefined)?.processingStatus === 'failed' + ) + expect(failure?.[0]).toMatchObject({ + processingError: 'Usage limit exceeded. Upgrade to continue.', + }) + const attempts = (failure?.[0] as Record).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 | 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 | 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 | undefined)?.processingStatus === 'failed' + ) + expect(failure).toBeDefined() + expect(failure?.[0]).toMatchObject({ + processingError: EMBEDDING_QUOTA_EXHAUSTED_MESSAGE, + }) + const attempts = (failure![0] as Record).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)[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)[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 | 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 | 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 | 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 | undefined)?.processingError === + 'continuation unavailable' + ) + expect(failure?.[0]).toMatchObject({ + processingStatus: 'failed', + processingDeferredUntil: null, + }) + }) }) diff --git a/apps/sim/lib/knowledge/documents/document-processor-chunk-limit.test.ts b/apps/sim/lib/knowledge/documents/document-processor-chunk-limit.test.ts new file mode 100644 index 00000000000..bb991e2154c --- /dev/null +++ b/apps/sim/lib/knowledge/documents/document-processor-chunk-limit.test.ts @@ -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) + }) + + 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) + }) + + 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) + }) +}) diff --git a/apps/sim/lib/knowledge/documents/document-processor.ts b/apps/sim/lib/knowledge/documents/document-processor.ts index 9572060cdf7..2096de0a199 100644 --- a/apps/sim/lib/knowledge/documents/document-processor.ts +++ b/apps/sim/lib/knowledge/documents/document-processor.ts @@ -5,6 +5,7 @@ import { PDFDocument } from 'pdf-lib' import { getBYOKKey } from '@/lib/api-key/byok' import { type Chunk, + ChunkLimitExceededError, JsonYamlChunker, RecursiveChunker, RegexChunker, @@ -14,10 +15,20 @@ import { TokenChunker, } from '@/lib/chunkers' import type { ChunkingStrategy, StrategyOptions } from '@/lib/chunkers/types' -import { env, envNumber } from '@/lib/core/config/env' +import { env } from '@/lib/core/config/env' import { OCR_CAPABILITY, requireCapability } from '@/lib/core/config/env-capabilities' +import { + DEFAULT_MAX_ERROR_BODY_BYTES, + isPayloadSizeLimitError, + readResponseTextWithLimit, +} from '@/lib/core/utils/stream-limits' import { parseBuffer } from '@/lib/file-parsers' -import type { FileParseMetadata } from '@/lib/file-parsers/types' +import { decodeDataUriWithinLimit } from '@/lib/file-parsers/data-uri' +import type { FileParseMetadata, FileParseResult } from '@/lib/file-parsers/types' +import { + MAX_DOCUMENT_CHUNKS, + PermanentDocumentProcessingError, +} from '@/lib/knowledge/documents/document-processing-error' import { resolveParserExtension, resolveStoredArtifactExtension, @@ -43,13 +54,28 @@ const TIMEOUTS = { MISTRAL_OCR_API: 120000, } as const -const MAX_CONCURRENT_CHUNKS = envNumber(env.KB_CONFIG_CHUNK_CONCURRENCY, 10) +const DEFAULT_OCR_CHUNK_CONCURRENCY = 2 +const MAX_OCR_PDF_PAGES = 10_000 +const MAX_OCR_PDF_CHUNKS = 10 +const MAX_OCR_SPLIT_BYTES = 2 * MAX_FILE_SIZE +const MAX_OCR_RESPONSE_BYTES = 32 * 1024 * 1024 +const MAX_OCR_OUTPUT_TEXT_BYTES = 20 * 1024 * 1024 + +/** + * Two concurrent OCR chunks keep the source, split-buffer, response, and + * extracted-text ceilings inside one worker's aggregate memory budget. + */ +const MAX_CONCURRENT_CHUNKS = DEFAULT_OCR_CHUNK_CONCURRENCY type OCRResult = { success: boolean error?: string output?: { content?: string + metadata?: { + pageCount?: number + usageInfo?: { pagesProcessed?: number } + } } } @@ -67,53 +93,51 @@ const LEGACY_FORMAT_REPLACEMENTS: Record = { const MISTRAL_MAX_PAGES = 1000 async function getPdfPageCount(buffer: Buffer): Promise { + let pdf: Awaited> | undefined try { const { getDocumentProxy } = await import('unpdf') const uint8Array = new Uint8Array(buffer) - const pdf = await getDocumentProxy(uint8Array) + pdf = await getDocumentProxy(uint8Array) return pdf.numPages } catch (error) { logger.warn('Failed to get PDF page count:', error) return 0 + } finally { + await pdf?.destroy().catch(() => {}) } } -async function splitPdfIntoChunks( - pdfBuffer: Buffer, - maxPages: number -): Promise<{ buffer: Buffer; startPage: number; endPage: number }[]> { - const sourcePdf = await PDFDocument.load(pdfBuffer) - const totalPages = sourcePdf.getPageCount() - - if (totalPages <= maxPages) { - return [{ buffer: pdfBuffer, startPage: 0, endPage: totalPages - 1 }] - } - - const chunks: { buffer: Buffer; startPage: number; endPage: number }[] = [] - - for (let startPage = 0; startPage < totalPages; startPage += maxPages) { - const endPage = Math.min(startPage + maxPages - 1, totalPages - 1) - const pageCount = endPage - startPage + 1 - - const newPdf = await PDFDocument.create() - const pageIndices = Array.from({ length: pageCount }, (_, i) => startPage + i) - const copiedPages = await newPdf.copyPages(sourcePdf, pageIndices) +interface PdfChunk { + buffer: Buffer + startPage: number + endPage: number +} - copiedPages.forEach((page) => newPdf.addPage(page)) +async function buildPdfChunk( + sourcePdf: PDFDocument, + startPage: number, + endPage: number +): Promise { + const outputPdf = await PDFDocument.create() + const pageIndices = Array.from( + { length: endPage - startPage + 1 }, + (_, index) => startPage + index + ) + const copiedPages = await outputPdf.copyPages(sourcePdf, pageIndices) + for (const page of copiedPages) outputPdf.addPage(page) - const pdfBytes = await newPdf.save() - chunks.push({ - buffer: Buffer.from(pdfBytes), - startPage, - endPage, - }) + return { + buffer: Buffer.from(await outputPdf.save()), + startPage, + endPage, } - - return chunks } type AzureOCRResponse = { pages?: OCRPage[] + usage_info?: { + pages_processed?: number + } [key: string]: unknown } @@ -135,7 +159,12 @@ async function applyStrategy( minCharactersPerChunk: number, strategyOptions?: StrategyOptions ): Promise { - const baseOptions = { chunkSize, chunkOverlap, minCharactersPerChunk } + const baseOptions = { + chunkSize, + chunkOverlap, + minCharactersPerChunk, + maxChunks: MAX_DOCUMENT_CHUNKS, + } switch (strategy) { case 'token': { @@ -214,48 +243,76 @@ export async function processDocument( * visible with a reason instead. */ if (parseResult.metadata?.degraded || !content.trim()) { - throw new Error(unreadableDocumentMessage(filename)) + throw new PermanentDocumentProcessingError( + 'no_extractable_text', + unreadableDocumentMessage(filename) + ) } let chunks: Chunk[] const metadata: FileParseMetadata = parseResult.metadata ?? {} - if (strategy && strategy !== 'auto') { - logger.info(`Using explicit chunking strategy: ${strategy}`) - chunks = await applyStrategy( - strategy, - content, - chunkSize, - chunkOverlap, - minCharactersPerChunk, - strategyOptions - ) - } else { - const isJsonYaml = - metadata.type === 'json' || - metadata.type === 'yaml' || - mimeType.includes('json') || - mimeType.includes('yaml') - - if (isJsonYaml && JsonYamlChunker.isStructuredData(content)) { - logger.info('Using JSON/YAML chunker for structured data') - chunks = await JsonYamlChunker.chunkJsonYaml(content, { + try { + if (strategy && strategy !== 'auto') { + logger.info(`Using explicit chunking strategy: ${strategy}`) + chunks = await applyStrategy( + strategy, + content, chunkSize, + chunkOverlap, minCharactersPerChunk, - }) - } else if (StructuredDataChunker.isStructuredData(content, mimeType)) { - logger.info('Using structured data chunker for spreadsheet/CSV content') - const rowCount = metadata.totalRows ?? metadata.rowCount - chunks = await StructuredDataChunker.chunkStructuredData(content, { - chunkSize, - headers: metadata.headers, - totalRows: typeof rowCount === 'number' ? rowCount : undefined, - sheetName: metadata.sheetNames?.[0], - }) + strategyOptions + ) } else { - const chunker = new TextChunker({ chunkSize, chunkOverlap, minCharactersPerChunk }) - chunks = await chunker.chunk(content) + const isJsonYaml = + metadata.type === 'json' || + metadata.type === 'yaml' || + mimeType.includes('json') || + mimeType.includes('yaml') + + if (isJsonYaml && JsonYamlChunker.isStructuredData(content)) { + logger.info('Using JSON/YAML chunker for structured data') + chunks = await JsonYamlChunker.chunkJsonYaml(content, { + chunkSize, + minCharactersPerChunk, + maxChunks: MAX_DOCUMENT_CHUNKS, + }) + } else if (StructuredDataChunker.isStructuredData(content, mimeType)) { + logger.info('Using structured data chunker for spreadsheet/CSV content') + const rowCount = metadata.totalRows ?? metadata.rowCount + chunks = await StructuredDataChunker.chunkStructuredData(content, { + chunkSize, + headers: metadata.headers, + totalRows: typeof rowCount === 'number' ? rowCount : undefined, + sheetName: metadata.sheetNames?.[0], + maxChunks: MAX_DOCUMENT_CHUNKS, + }) + } else { + const chunker = new TextChunker({ + chunkSize, + chunkOverlap, + minCharactersPerChunk, + maxChunks: MAX_DOCUMENT_CHUNKS, + }) + chunks = await chunker.chunk(content) + } } + } catch (error) { + if (error instanceof ChunkLimitExceededError) { + throw new PermanentDocumentProcessingError( + 'document_complexity_limit', + `This document would produce more than ${error.maxChunks.toLocaleString()} index chunks. Split it into smaller files or increase its knowledge-base chunk size, then retry.`, + error + ) + } + throw error + } + + if (chunks.length === 0) { + throw new PermanentDocumentProcessingError( + 'no_extractable_text', + `The chunking strategy produced no indexable text for ${filename}. Adjust the chunking settings or replace the document content, then retry.` + ) } const characterCount = content.length @@ -495,11 +552,7 @@ async function downloadFileWithTimeout(fileUrl: string, userId?: string): Promis async function downloadFileForBase64(fileUrl: string, userId?: string): Promise { if (/^data:/i.test(fileUrl)) { - const [, base64Data] = fileUrl.split(',') - if (!base64Data) { - throw new Error('Invalid data URI format') - } - return Buffer.from(base64Data, 'base64') + return decodeDataUriWithinLimit(fileUrl, MAX_FILE_SIZE).buffer } if (/^https?:\/\//i.test(fileUrl) || isInternalFileUrl(fileUrl)) { return downloadFileWithTimeout(fileUrl, userId) @@ -509,16 +562,53 @@ async function downloadFileForBase64(fileUrl: string, userId?: string): Promise< ) } -function processOCRContent(result: OCRResult): string { +function assertOcrOutputTextWithinLimit(content: string): void { + const outputBytes = Buffer.byteLength(content, 'utf8') + if (outputBytes <= MAX_OCR_OUTPUT_TEXT_BYTES) return + + throw new PermanentDocumentProcessingError( + 'document_complexity_limit', + `OCR extracted ${outputBytes.toLocaleString()} bytes of text, exceeding the safe limit of ${MAX_OCR_OUTPUT_TEXT_BYTES.toLocaleString()} bytes. Split the document into smaller files and retry.` + ) +} + +function processOCRContent(result: OCRResult, filename: string, expectedPages?: number): string { if (!result.success) { throw new Error(`OCR processing failed: ${result.error || 'Unknown error'}`) } const content = result.output?.content || '' + const pageCount = result.output?.metadata?.pageCount + const pagesProcessed = result.output?.metadata?.usageInfo?.pagesProcessed + if (pageCount === 0) { + throw new Error('OCR provider returned no page results') + } + if ( + expectedPages !== undefined && + (pageCount !== expectedPages || + (Number.isFinite(pagesProcessed) && pagesProcessed !== expectedPages)) + ) { + throw new Error( + `OCR provider returned an incomplete page result: expected ${expectedPages}, received ${pageCount ?? 0}` + ) + } + if ( + expectedPages === undefined && + (!Number.isFinite(pageCount) || + !Number.isFinite(pagesProcessed) || + pageCount !== pagesProcessed) + ) { + throw new Error('OCR provider did not report a complete page count for this PDF') + } if (!content.trim()) { - throw new Error('OCR returned empty content') + throw new PermanentDocumentProcessingError( + 'no_extractable_text', + unreadableDocumentMessage(filename) + ) } + assertOcrOutputTextWithinLimit(content) + logger.info('OCR completed') return content } @@ -559,23 +649,43 @@ async function makeOCRRequest( signal: controller.signal, }) - clearTimeout(timeoutId) + const responseBodyLimit = response.ok ? MAX_OCR_RESPONSE_BYTES : DEFAULT_MAX_ERROR_BODY_BYTES + let responseText: string + try { + responseText = await readResponseTextWithLimit(response, { + maxBytes: responseBodyLimit, + label: response.ok ? 'OCR success response' : 'OCR error response', + signal: controller.signal, + }) + } catch (error) { + if (response.ok && isPayloadSizeLimitError(error)) { + throw new Error( + `OCR provider response exceeded the safe envelope limit of ${MAX_OCR_RESPONSE_BYTES} bytes`, + { cause: error } + ) + } + if (!response.ok && isPayloadSizeLimitError(error)) { + responseText = '' + } else { + throw error + } + } if (!response.ok) { - const errorText = await response.text() - throw new APIError( - `OCR failed: ${response.status} ${response.statusText} - ${errorText}`, - response.status - ) + throw new APIError(`OCR failed: ${response.status}`, response.status) } - return response + return new Response(responseText, { + status: response.status, + headers: response.headers, + }) } catch (error) { - clearTimeout(timeoutId) if (error instanceof Error && error.name === 'AbortError') { throw new Error('OCR API request timed out') } throw error + } finally { + clearTimeout(timeoutId) } } @@ -603,15 +713,25 @@ async function parseWithAzureMistralOCR( */ const content = mimeType === 'application/pdf' - ? await ocrPdfInChunks(fileBuffer, 'azure-mistral', (chunk) => - recognizeWithAzureOCR(chunk.buffer, mimeType) - ) + ? await ocrPdfInChunks(fileBuffer, 'azure-mistral', filename, (chunk) => { + const pageCount = chunk.endPage - chunk.startPage + 1 + return recognizeWithAzureOCR( + chunk.buffer, + mimeType, + pageCount > 0 ? pageCount : undefined + ) + }) : await recognizeWithAzureOCR(fileBuffer, mimeType) if (!content.trim()) { - throw new Error('Azure Mistral OCR returned empty content') + throw new PermanentDocumentProcessingError( + 'no_extractable_text', + unreadableDocumentMessage(filename) + ) } + assertOcrOutputTextWithinLimit(content) + logger.info('Azure Mistral OCR completed') return { content, processingMethod: 'mistral-ocr' as const, cloudUrl: undefined } } catch (error) { @@ -623,7 +743,11 @@ async function parseWithAzureMistralOCR( } /** Sends one document to Azure Mistral OCR inline, as a base64 data URI. */ -async function recognizeWithAzureOCR(buffer: Buffer, mimeType: string): Promise { +async function recognizeWithAzureOCR( + buffer: Buffer, + mimeType: string, + expectedPages?: number +): Promise { const dataUri = `data:${mimeType};base64,${buffer.toString('base64')}` const response = await retryWithExponentialBackoff( @@ -648,13 +772,34 @@ async function recognizeWithAzureOCR(buffer: Buffer, mimeType: string): Promise< const ocrResult = (await response.json()) as AzureOCRResponse + const returnedPages = ocrResult.pages?.length ?? 0 + const processedPages = ocrResult.usage_info?.pages_processed + if (expectedPages !== undefined) { + if ( + returnedPages !== expectedPages || + (processedPages !== undefined && processedPages !== expectedPages) + ) { + throw new Error( + `OCR provider returned an incomplete page result: expected ${expectedPages}, received ${returnedPages}` + ) + } + } else if ( + returnedPages === 0 || + !Number.isFinite(processedPages) || + processedPages !== returnedPages + ) { + throw new Error('OCR provider did not report a complete page count for this PDF') + } + /** * A response carrying no pages is no content. Returning the raw payload instead * would be indexed as though it were the document: stitched into a chunked run * as recovered text, and in a single-document run it would satisfy the * empty-content check that exists to catch exactly this. */ - return extractPageContent(ocrResult.pages || []) + const content = extractPageContent(ocrResult.pages || []) + assertOcrOutputTextWithinLimit(content) + return content } async function parseWithMistralOCR( @@ -700,7 +845,7 @@ async function parseWithMistralOCR( try { const response = await executeMistralOCRRequest(params, userId) const result = (await mistralParserTool.transformResponse!(response, params)) as OCRResult - const content = processOCRContent(result) + const content = processOCRContent(result, filename, pageCount > 0 ? pageCount : undefined) return { content, processingMethod: 'mistral-ocr' as const, cloudUrl } } catch (error) { @@ -751,7 +896,7 @@ async function processChunk( filename: string, apiKey: string, userId?: string -): Promise<{ index: number; content: string | null }> { +): Promise { const chunkPageCount = chunk.endPage - chunk.startPage + 1 logger.info( @@ -799,17 +944,31 @@ async function processChunk( const response = await executeMistralOCRRequest(params, userId) const result = (await mistralParserTool.transformResponse!(response, params)) as OCRResult - if (result.success && result.output?.content) { - logger.info(`Chunk ${chunkIndex + 1}/${totalChunks} completed successfully`) - return { index: chunkIndex, content: result.output.content } + if (!result.success) { + throw new Error(`OCR processing failed: ${result.error || 'Unknown error'}`) } - logger.warn(`Chunk ${chunkIndex + 1}/${totalChunks} returned no content`) - return { index: chunkIndex, content: null } + + const pageCount = result.output?.metadata?.pageCount + const pagesProcessed = result.output?.metadata?.usageInfo?.pagesProcessed + if ( + chunkPageCount > 0 && + (pageCount !== chunkPageCount || + (Number.isFinite(pagesProcessed) && pagesProcessed !== chunkPageCount)) + ) { + throw new Error( + `OCR provider returned an incomplete page result: expected ${chunkPageCount}, received ${pageCount}` + ) + } + + const content = result.output?.content ?? '' + assertOcrOutputTextWithinLimit(content) + logger.info(`Chunk ${chunkIndex + 1}/${totalChunks} completed successfully`) + return content } catch (error) { logger.error(`Chunk ${chunkIndex + 1}/${totalChunks} failed:`, { errorType: toError(error).name, }) - return { index: chunkIndex, content: null } + throw error } finally { if (uploadedKey) { try { @@ -840,13 +999,14 @@ async function processChunk( async function ocrPdfInChunks( pdfBuffer: Buffer, provider: string, + filename: string, recognize: ( chunk: { buffer: Buffer; startPage: number; endPage: number }, chunkIndex: number, totalChunks: number ) => Promise ): Promise { - const totalPages = await getPdfPageCount(pdfBuffer) + const detectedPageCount = await getPdfPageCount(pdfBuffer) /** * Splitting has to load the document, which an encrypted or malformed PDF will @@ -856,68 +1016,135 @@ async function ocrPdfInChunks( * split fails the document is sent whole and the page cap is left to the * provider — the behaviour before it was chunked. */ - let pdfChunks: { buffer: Buffer; startPage: number; endPage: number }[] + let sourcePdf: PDFDocument | null = null + let totalPages = detectedPageCount try { - pdfChunks = await splitPdfIntoChunks(pdfBuffer, MISTRAL_MAX_PAGES) + sourcePdf = await PDFDocument.load(pdfBuffer) + totalPages = sourcePdf.getPageCount() } catch (error) { logger.info('PDF could not be split for OCR, sending it whole', { provider, error: toError(error).message, }) - pdfChunks = [{ buffer: pdfBuffer, startPage: 0, endPage: Math.max(0, totalPages - 1) }] + } + + const requiresSplitting = sourcePdf !== null && totalPages > MISTRAL_MAX_PAGES + const chunkCount = requiresSplitting ? Math.ceil(totalPages / MISTRAL_MAX_PAGES) : 1 + if (totalPages > MAX_OCR_PDF_PAGES || chunkCount > MAX_OCR_PDF_CHUNKS) { + throw new PermanentDocumentProcessingError( + 'document_complexity_limit', + `This PDF has ${totalPages.toLocaleString()} pages, exceeding the safe OCR limit of ${MAX_OCR_PDF_PAGES.toLocaleString()}. Split it into smaller files and retry.` + ) } logger.info('Splitting PDF for OCR', { provider, totalPages, - chunks: pdfChunks.length, + chunks: chunkCount, maxPagesPerChunk: MISTRAL_MAX_PAGES, concurrency: MAX_CONCURRENT_CHUNKS, }) - const results: { index: number; content: string | null }[] = [] + type ChunkOutcome = + | { index: number; kind: 'content'; content: string } + | { index: number; kind: 'empty' } + | { index: number; kind: 'failure'; error: unknown } + + const outcomes: ChunkOutcome[] = [] + let cumulativeSplitBytes = 0 + let cumulativeOutputBytes = 0 + + for (let i = 0; i < chunkCount; i += MAX_CONCURRENT_CHUNKS) { + const batchEnd = Math.min(i + MAX_CONCURRENT_CHUNKS, chunkCount) + const batch: PdfChunk[] = [] + for (let index = i; index < batchEnd; index++) { + let chunk: PdfChunk + if (requiresSplitting && sourcePdf) { + const startPage = index * MISTRAL_MAX_PAGES + const endPage = Math.min(startPage + MISTRAL_MAX_PAGES - 1, totalPages - 1) + chunk = await buildPdfChunk(sourcePdf, startPage, endPage) + cumulativeSplitBytes += chunk.buffer.length + if (cumulativeSplitBytes > MAX_OCR_SPLIT_BYTES) { + throw new PermanentDocumentProcessingError( + 'document_complexity_limit', + `Splitting this PDF for OCR exceeded the safe cumulative limit of ${MAX_OCR_SPLIT_BYTES.toLocaleString()} bytes. Split it into smaller files and retry.` + ) + } + } else { + chunk = { + buffer: pdfBuffer, + startPage: 0, + endPage: totalPages > 0 ? totalPages - 1 : -1, + } + } + batch.push(chunk) + } - for (let i = 0; i < pdfChunks.length; i += MAX_CONCURRENT_CHUNKS) { - const batch = pdfChunks.slice(i, i + MAX_CONCURRENT_CHUNKS) const batchResults = await Promise.all( - batch.map((chunk, batchIndex) => { + batch.map(async (chunk, batchIndex): Promise => { const index = i + batchIndex - return recognize(chunk, index, pdfChunks.length).then( - (content) => ({ index, content }), - (error) => { - logger.warn('OCR chunk failed', { - provider, - chunk: index + 1, - error: toError(error).message, - }) - return { index, content: null } - } - ) + try { + const content = await recognize(chunk, index, chunkCount) + return content && content.trim().length > 0 + ? { index, kind: 'content', content } + : { index, kind: 'empty' } + } catch (error) { + logger.warn('OCR chunk failed', { + provider, + chunk: index + 1, + error: toError(error).message, + }) + return { index, kind: 'failure', error } + } }) ) - results.push(...batchResults) + for (const outcome of batchResults) { + if (outcome.kind !== 'content') continue + cumulativeOutputBytes += Buffer.byteLength(outcome.content, 'utf8') + if (cumulativeOutputBytes > MAX_OCR_OUTPUT_TEXT_BYTES) { + throw new PermanentDocumentProcessingError( + 'document_complexity_limit', + `OCR extracted more than the safe limit of ${MAX_OCR_OUTPUT_TEXT_BYTES.toLocaleString()} bytes of text. Split the document into smaller files and retry.` + ) + } + } + outcomes.push(...batchResults) + } + + const failures = outcomes.filter( + (outcome): outcome is Extract => outcome.kind === 'failure' + ) + if (failures.length > 0) { + const permanentFailure = failures.find( + (failure) => failure.error instanceof PermanentDocumentProcessingError + )?.error + if (permanentFailure) throw permanentFailure + + throw new Error( + `OCR completed ${outcomes.length - failures.length} of ${chunkCount} chunks; indexing the document would omit the rest`, + { cause: new AggregateError(failures.map((failure) => failure.error)) } + ) } - const recovered = results + const recovered = outcomes .sort((a, b) => a.index - b.index) - .map((r) => r.content) - .filter((content): content is string => content !== null && content.trim().length > 0) + .flatMap((outcome) => (outcome.kind === 'content' ? [outcome.content] : [])) /** - * Each chunk has already exhausted its own retries, so a missing one is a real - * failure rather than a blip. Failing the document leaves it visible with a - * reason and eligible for the stuck-document sweep, which can retry it and - * produce a complete result — whereas indexing what came back would be - * indistinguishable from a document that never had those pages. + * Provider-specific checks already proved that every requested page was + * represented. If every complete range is nevertheless blank, the document + * has no text to index; a mixture of blank and nonblank pages remains valid. */ - if (recovered.length < pdfChunks.length) { - throw new Error( - `OCR recovered ${recovered.length} of ${pdfChunks.length} chunks; ` + - 'indexing the document would omit the rest' + if (recovered.length === 0) { + throw new PermanentDocumentProcessingError( + 'no_extractable_text', + unreadableDocumentMessage(filename) ) } - return recovered.join('\n\n') + const content = recovered.join('\n\n') + assertOcrOutputTextWithinLimit(content) + return content } async function processMistralOCRInBatches( @@ -931,8 +1158,8 @@ async function processMistralOCRInBatches( processingMethod: 'mistral-ocr' cloudUrl?: string }> { - const content = await ocrPdfInChunks(pdfBuffer, 'mistral', (chunk, index, total) => - processChunk(chunk, index, total, filename, apiKey, userId).then((r) => r.content) + const content = await ocrPdfInChunks(pdfBuffer, 'mistral', filename, (chunk, index, total) => + processChunk(chunk, index, total, filename, apiKey, userId) ) return { content, processingMethod: 'mistral-ocr', cloudUrl } @@ -966,7 +1193,9 @@ async function parseWithFileParser( let metadata: FileParseMetadata = {} if (/^data:/i.test(fileUrl)) { - content = await parseDataURI(fileUrl, filename, mimeType) + const result = await parseDataURI(fileUrl, filename, mimeType) + content = result.content + metadata = result.metadata || {} } else if (/^https?:\/\//i.test(fileUrl) || isInternalFileUrl(fileUrl)) { // Internal URLs may arrive as an app-relative `/api/files/serve/...` path // (some ingestion callers store the relative path); downloadFileFromUrl @@ -987,22 +1216,15 @@ async function parseWithFileParser( } } -async function parseDataURI(fileUrl: string, filename: string, mimeType: string): Promise { - const [header, base64Data] = fileUrl.split(',') - if (!base64Data) { - throw new Error('Invalid data URI format') - } - - if (mimeType === 'text/plain') { - return header.includes('base64') - ? Buffer.from(base64Data, 'base64').toString('utf8') - : decodeURIComponent(base64Data) - } - +async function parseDataURI( + fileUrl: string, + filename: string, + mimeType: string +): Promise { + const { buffer, mediaType } = decodeDataUriWithinLimit(fileUrl, MAX_FILE_SIZE) const extension = resolveParserExtension(filename, mimeType, 'txt') - const buffer = Buffer.from(base64Data, 'base64') - const result = await parseBuffer(buffer, extension) - return result.content + logger.info('Parsing bounded data URI', { bytes: buffer.length, mediaType, extension }) + return parseBuffer(buffer, extension) } async function parseHttpFile( diff --git a/apps/sim/lib/knowledge/documents/pdf-ocr-triage.test.ts b/apps/sim/lib/knowledge/documents/pdf-ocr-triage.test.ts index 48a29293d47..75d5a07f78b 100644 --- a/apps/sim/lib/knowledge/documents/pdf-ocr-triage.test.ts +++ b/apps/sim/lib/knowledge/documents/pdf-ocr-triage.test.ts @@ -28,6 +28,7 @@ vi.mock('@/lib/file-parsers', () => ({ vi.mock('@/lib/uploads/utils/file-utils.server', () => ({ downloadFileFromUrl: mockDownload })) import { env } from '@/lib/core/config/env' +import { PermanentDocumentProcessingError } from '@/lib/knowledge/documents/document-processing-error' import { processDocument } from '@/lib/knowledge/documents/document-processor' import { runWithKnowledgeModelInputProvenance } from '@/lib/knowledge/model-input-provenance' @@ -43,6 +44,10 @@ async function pdfOfPages(count: number): Promise { return Buffer.from(await pdf.save()) } +function ocrPages(count: number, markdown = 'Recognised page') { + return Array.from({ length: count }, () => ({ markdown })) +} + function parse() { return runWithKnowledgeModelInputProvenance( undefined, @@ -85,10 +90,13 @@ describe('PDF OCR triage', () => { const headerOnly = 'CONFIDENTIAL - Vendor Master Agreement - Page header. '.repeat(6) mockParseBuffer.mockResolvedValue({ content: headerOnly, metadata: { pageCount: 80 } }) const fetchMock = vi.fn().mockResolvedValue( - new Response(JSON.stringify({ pages: [{ markdown: 'Recognised' }], usage_info: {} }), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }) + new Response( + JSON.stringify({ pages: [{ markdown: 'Recognised' }], usage_info: { pages_processed: 1 } }), + { + status: 200, + headers: { 'Content-Type': 'application/json' }, + } + ) ) vi.stubGlobal('fetch', fetchMock) @@ -100,10 +108,13 @@ describe('PDF OCR triage', () => { it('falls through to OCR when the PDF is a scan', async () => { mockParseBuffer.mockResolvedValue({ content: '', metadata: {} }) const fetchMock = vi.fn().mockResolvedValue( - new Response(JSON.stringify({ pages: [{ markdown: 'Recognised text' }], usage_info: {} }), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }) + new Response( + JSON.stringify({ + pages: [{ markdown: 'Recognised text' }], + usage_info: { pages_processed: 1 }, + }), + { status: 200, headers: { 'Content-Type': 'application/json' } } + ) ) vi.stubGlobal('fetch', fetchMock) @@ -121,10 +132,13 @@ describe('PDF OCR triage', () => { it('falls through to OCR when the text layer is raw CID escapes', async () => { mockParseBuffer.mockResolvedValue({ content: '/31 /8 /18 /12 /44 '.repeat(60), metadata: {} }) const fetchMock = vi.fn().mockResolvedValue( - new Response(JSON.stringify({ pages: [{ markdown: 'Recognised' }], usage_info: {} }), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }) + new Response( + JSON.stringify({ pages: [{ markdown: 'Recognised' }], usage_info: { pages_processed: 1 } }), + { + status: 200, + headers: { 'Content-Type': 'application/json' }, + } + ) ) vi.stubGlobal('fetch', fetchMock) @@ -137,10 +151,13 @@ describe('PDF OCR triage', () => { it('falls through to OCR when the text layer cannot be parsed at all', async () => { mockParseBuffer.mockRejectedValue(new Error('Invalid PDF structure.')) const fetchMock = vi.fn().mockResolvedValue( - new Response(JSON.stringify({ pages: [{ markdown: 'Recognised' }], usage_info: {} }), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }) + new Response( + JSON.stringify({ pages: [{ markdown: 'Recognised' }], usage_info: { pages_processed: 1 } }), + { + status: 200, + headers: { 'Content-Type': 'application/json' }, + } + ) ) vi.stubGlobal('fetch', fetchMock) @@ -148,6 +165,21 @@ describe('PDF OCR triage', () => { expect(result.metadata.processingMethod).toBe('mistral-ocr') }) + + it('does not index a Mistral no-pages response as raw provider JSON', async () => { + mockParseBuffer.mockResolvedValue({ content: '', metadata: {} }) + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + new Response(JSON.stringify({ pages: [], usage_info: { pages_processed: 0 } }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + ) + ) + + await expect(parse()).rejects.toThrow('OCR provider returned no page results') + }) }) describe('Azure OCR chunking', () => { @@ -166,13 +198,17 @@ describe('Azure OCR chunking', () => { mockParseBuffer.mockResolvedValue({ content: '', metadata: { pageCount: 2500 } }) mockDownload.mockResolvedValue(await pdfOfPages(1001)) // A fresh Response per call: a body can only be read once. - const fetchMock = vi.fn().mockImplementation( - async () => - new Response(JSON.stringify({ pages: [{ markdown: 'Recognised page' }] }), { + let request = 0 + const fetchMock = vi.fn().mockImplementation(async () => { + const pageCount = request++ === 0 ? 1000 : 1 + return new Response( + JSON.stringify({ pages: ocrPages(pageCount), usage_info: { pages_processed: pageCount } }), + { status: 200, headers: { 'Content-Type': 'application/json' }, - }) - ) + } + ) + }) vi.stubGlobal('fetch', fetchMock) const result = await parse() @@ -196,10 +232,16 @@ describe('Azure OCR chunking', () => { mockParseBuffer.mockRejectedValue(new Error('Invalid PDF structure.')) mockDownload.mockResolvedValue(Buffer.from('not something pdf-lib can load')) const fetchMock = vi.fn().mockResolvedValue( - new Response(JSON.stringify({ pages: [{ markdown: 'Recognised' }] }), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }) + new Response( + JSON.stringify({ + pages: [{ markdown: 'Recognised' }], + usage_info: { pages_processed: 1 }, + }), + { + status: 200, + headers: { 'Content-Type': 'application/json' }, + } + ) ) vi.stubGlobal('fetch', fetchMock) @@ -214,7 +256,7 @@ describe('Azure OCR chunking', () => { * the API envelope as the document and satisfy the empty-content check meant to * catch it. */ - it('treats an Azure response carrying no pages as empty, not as content', async () => { + it('treats an Azure response carrying no processed pages as an incomplete provider response', async () => { Object.assign(env, { OCR_PROVIDER: 'azure-mistral', OCR_AZURE_API_KEY: 'key', @@ -233,8 +275,11 @@ describe('Azure OCR chunking', () => { ) ) - // Counted as a failed chunk rather than stitched in as recovered text. - await expect(parse()).rejects.toThrow(/OCR recovered 0 of 1 chunks/) + const error = await parse().catch((caught: unknown) => caught) + + expect(error).toBeInstanceOf(Error) + expect(error).not.toBeInstanceOf(PermanentDocumentProcessingError) + expect(error).toMatchObject({ message: expect.stringMatching(/completed 0 of 1 chunks/) }) }) /** @@ -258,15 +303,54 @@ describe('Azure OCR chunking', () => { vi.fn().mockImplementation(async () => { call++ if (call === 1) { - return new Response(JSON.stringify({ pages: [{ markdown: 'First half' }] }), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }) + return new Response( + JSON.stringify({ + pages: ocrPages(1000, 'First half'), + usage_info: { pages_processed: 1000 }, + }), + { status: 200, headers: { 'Content-Type': 'application/json' } } + ) } return new Response('upstream failure', { status: 500 }) }) ) - await expect(parse()).rejects.toThrow(/OCR recovered 1 of 2 chunks/) + const error = await parse().catch((caught: unknown) => caught) + + expect(error).toBeInstanceOf(Error) + expect(error).not.toBeInstanceOf(PermanentDocumentProcessingError) + expect(error).toMatchObject({ message: expect.stringMatching(/OCR completed 1 of 2 chunks/) }) + }) + + it('accepts a page-complete blank range when another range contains text', async () => { + Object.assign(env, { + OCR_PROVIDER: 'azure-mistral', + OCR_AZURE_API_KEY: 'key', + OCR_AZURE_ENDPOINT: 'https://example.openai.azure.com', + OCR_AZURE_MODEL_NAME: 'mistral-ocr', + }) + mockParseBuffer.mockResolvedValue({ content: '', metadata: {} }) + mockDownload.mockResolvedValue(await pdfOfPages(1001)) + + let request = 0 + vi.stubGlobal( + 'fetch', + vi.fn().mockImplementation(async () => { + const first = request++ === 0 + const pageCount = first ? 1000 : 1 + return new Response( + JSON.stringify({ + pages: ocrPages(pageCount, first ? 'First half' : ''), + usage_info: { pages_processed: pageCount }, + }), + { status: 200, headers: { 'Content-Type': 'application/json' } } + ) + }) + ) + + const result = await parse() + + expect(result.chunks.some((chunk) => chunk.text.includes('First half'))).toBe(true) + expect(result.metadata.processingMethod).toBe('mistral-ocr') }) }) diff --git a/apps/sim/lib/knowledge/documents/processing-claim.test.ts b/apps/sim/lib/knowledge/documents/processing-claim.test.ts index d09d85505ec..31de6382c35 100644 --- a/apps/sim/lib/knowledge/documents/processing-claim.test.ts +++ b/apps/sim/lib/knowledge/documents/processing-claim.test.ts @@ -2,7 +2,7 @@ * @vitest-environment node */ -import { dbChainMockFns, hasMockCondition, resetDbChainMock } from '@sim/testing' +import { dbChainMockFns, hasMockCondition, resetDbChainMock, schemaMock } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' import { failStaleDocumentProcessingClaim, @@ -48,6 +48,8 @@ describe('reclaimStaleDocumentProcessingClaim', () => { expect(reclaimed).toBe(true) expect(dbChainMockFns.set).toHaveBeenCalledWith({ processingStatus: 'pending', + processingQueueToken: null, + processingQueuedAt: null, processingStartedAt: null, processingCompletedAt: null, processingError: null, @@ -80,6 +82,7 @@ describe('failStaleDocumentProcessingClaim', () => { it('rejects an active processing claim', async () => { await expect( failStaleDocumentProcessingClaim({ + knowledgeBaseId: 'knowledge-base-1', documentId: 'document-1', processingStartedAt: new Date( NOW.getTime() - KNOWLEDGE_DOCUMENT_PROCESSING_STALE_THRESHOLD_MS @@ -98,6 +101,7 @@ describe('failStaleDocumentProcessingClaim', () => { dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'document-1' }]) const result = await failStaleDocumentProcessingClaim({ + knowledgeBaseId: 'knowledge-base-1', documentId: 'document-1', processingStartedAt, now: NOW, @@ -110,6 +114,7 @@ describe('failStaleDocumentProcessingClaim', () => { expect(dbChainMockFns.set).toHaveBeenCalledWith({ processingStatus: 'failed', processingError: 'Processing timed out. Please retry or re-sync the connector.', + processingDeferredUntil: null, processingCompletedAt: NOW, }) }) @@ -118,6 +123,7 @@ describe('failStaleDocumentProcessingClaim', () => { dbChainMockFns.returning.mockResolvedValueOnce([]) const result = await failStaleDocumentProcessingClaim({ + knowledgeBaseId: 'knowledge-base-1', documentId: 'document-1', processingStartedAt: new Date( NOW.getTime() - KNOWLEDGE_DOCUMENT_PROCESSING_STALE_THRESHOLD_MS - 1 @@ -126,6 +132,31 @@ describe('failStaleDocumentProcessingClaim', () => { }) expect(result.success).toBe(false) + + const where = dbChainMockFns.where.mock.calls[0]?.[0] + expect( + hasMockCondition( + where, + (node) => + node.type === 'eq' && + node.left === schemaMock.document.knowledgeBaseId && + node.right === 'knowledge-base-1' + ) + ).toBe(true) + expect( + hasMockCondition( + where, + (node) => + node.type === 'eq' && + node.left === schemaMock.document.userExcluded && + node.right === false + ) + ).toBe(true) + for (const column of [schemaMock.document.archivedAt, schemaMock.document.deletedAt]) { + expect( + hasMockCondition(where, (node) => node.type === 'isNull' && node.column === column) + ).toBe(true) + } }) }) @@ -141,6 +172,7 @@ describe('failUndispatchedDocumentProcessing', () => { const failed = await failUndispatchedDocumentProcessing({ documentId: 'document-1', knowledgeBaseId: 'knowledge-base-1', + processingQueueToken: 'request-1', error: 'Failed to start processing', now: NOW, }) @@ -149,6 +181,7 @@ describe('failUndispatchedDocumentProcessing', () => { expect(dbChainMockFns.set).toHaveBeenCalledWith({ processingStatus: 'failed', processingError: 'Failed to start processing', + processingDeferredUntil: null, processingCompletedAt: NOW, }) }) @@ -164,6 +197,7 @@ describe('failUndispatchedDocumentProcessing', () => { const failed = await failUndispatchedDocumentProcessing({ documentId: 'document-1', knowledgeBaseId: 'knowledge-base-1', + processingQueueToken: 'request-1', error: 'Failed to start processing', now: NOW, }) @@ -183,7 +217,43 @@ describe('failUndispatchedDocumentProcessing', () => { expect( hasMockCondition( where, - (node) => node.type === 'isNull' && node.column === 'document.deletedAt' + (node) => + node.type === 'eq' && + node.left === schemaMock.document.processingQueueToken && + node.right === 'request-1' + ) + ).toBe(true) + expect( + hasMockCondition( + where, + (node) => node.type === 'isNull' && node.column === schemaMock.document.processingQueueToken + ) + ).toBe(false) + expect( + hasMockCondition( + where, + (node) => node.type === 'isNull' && node.column === schemaMock.document.processingQueuedAt + ) + ).toBe(true) + expect( + hasMockCondition( + where, + (node) => + node.type === 'eq' && + node.left === schemaMock.document.userExcluded && + node.right === false + ) + ).toBe(true) + expect( + hasMockCondition( + where, + (node) => node.type === 'isNull' && node.column === schemaMock.document.archivedAt + ) + ).toBe(true) + expect( + hasMockCondition( + where, + (node) => node.type === 'isNull' && node.column === schemaMock.document.deletedAt ) ).toBe(true) }) diff --git a/apps/sim/lib/knowledge/documents/processing-claim.ts b/apps/sim/lib/knowledge/documents/processing-claim.ts index 8d4389b54cb..82b5b4aaf57 100644 --- a/apps/sim/lib/knowledge/documents/processing-claim.ts +++ b/apps/sim/lib/knowledge/documents/processing-claim.ts @@ -4,11 +4,11 @@ import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { truncate } from '@sim/utils/string' import { and, eq, isNull } from 'drizzle-orm' -import { KNOWLEDGE_DOCUMENT_PROCESSING_STALE_THRESHOLD_MS } from '@/lib/knowledge/constants' +import { DOCUMENT_PROCESSING_STALE_THRESHOLD_MS } from '@/lib/knowledge/documents/processing-timeouts.server' const logger = createLogger('KnowledgeDocumentProcessingClaim') -export { KNOWLEDGE_DOCUMENT_PROCESSING_STALE_THRESHOLD_MS } from '@/lib/knowledge/constants' +export { DOCUMENT_PROCESSING_STALE_THRESHOLD_MS as KNOWLEDGE_DOCUMENT_PROCESSING_STALE_THRESHOLD_MS } from '@/lib/knowledge/documents/processing-timeouts.server' interface ReclaimStaleDocumentProcessingClaimParams { knowledgeBaseId: string @@ -18,6 +18,7 @@ interface ReclaimStaleDocumentProcessingClaimParams { } interface FailStaleDocumentProcessingClaimParams { + knowledgeBaseId: string documentId: string processingStartedAt: Date now?: Date @@ -35,8 +36,7 @@ export async function reclaimStaleDocumentProcessingClaim({ }: ReclaimStaleDocumentProcessingClaimParams): Promise { if ( processingStartedAt && - now.getTime() - processingStartedAt.getTime() <= - KNOWLEDGE_DOCUMENT_PROCESSING_STALE_THRESHOLD_MS + now.getTime() - processingStartedAt.getTime() <= DOCUMENT_PROCESSING_STALE_THRESHOLD_MS ) { return false } @@ -48,6 +48,8 @@ export async function reclaimStaleDocumentProcessingClaim({ .update(document) .set({ processingStatus: 'pending', + processingQueueToken: null, + processingQueuedAt: null, processingStartedAt: null, processingCompletedAt: null, processingError: null, @@ -70,6 +72,7 @@ export async function reclaimStaleDocumentProcessingClaim({ /** Marks only the abandoned processing attempt identified by its start-time token as failed. */ export async function failStaleDocumentProcessingClaim({ + knowledgeBaseId, documentId, processingStartedAt, now = new Date(), @@ -78,7 +81,7 @@ export async function failStaleDocumentProcessingClaim({ processingDuration: number }> { const processingDuration = now.getTime() - processingStartedAt.getTime() - if (processingDuration <= KNOWLEDGE_DOCUMENT_PROCESSING_STALE_THRESHOLD_MS) { + if (processingDuration <= DOCUMENT_PROCESSING_STALE_THRESHOLD_MS) { throw new Error('Document has not been processing long enough to be considered dead') } @@ -87,13 +90,18 @@ export async function failStaleDocumentProcessingClaim({ .set({ processingStatus: 'failed', processingError: 'Processing timed out. Please retry or re-sync the connector.', + processingDeferredUntil: null, processingCompletedAt: now, }) .where( and( eq(document.id, documentId), + eq(document.knowledgeBaseId, knowledgeBaseId), eq(document.processingStatus, 'processing'), - eq(document.processingStartedAt, processingStartedAt) + eq(document.processingStartedAt, processingStartedAt), + eq(document.userExcluded, false), + isNull(document.archivedAt), + isNull(document.deletedAt) ) ) .returning({ id: document.id }) @@ -104,6 +112,7 @@ export async function failStaleDocumentProcessingClaim({ interface FailUndispatchedDocumentProcessingParams { documentId: string knowledgeBaseId: string + processingQueueToken: string error: string now?: Date } @@ -118,14 +127,16 @@ interface FailUndispatchedDocumentProcessingParams { * as any other processing failure: visible in the document list with its error, * and re-queueable. * - * Guarded on `pending` so it cannot overwrite a document a worker has already - * claimed — the dispatch may have been accepted and only its acknowledgement - * lost. A worker that starts late still moves the row to `processing` - * unconditionally, so this write never strands a job that does run. + * Guarded on active `pending` state and the dispatch generation so it cannot + * overwrite a worker that already claimed the row, a newer queued pass, or a + * recent pre-token pass that may still start. A failed dispatch retains its + * generation token while withdrawing the live queue timestamp, so finalization + * can use one exact compare-and-set instead of matching an ambiguous blank row. */ export async function failUndispatchedDocumentProcessing({ documentId, knowledgeBaseId, + processingQueueToken, error, now = new Date(), }: FailUndispatchedDocumentProcessingParams): Promise { @@ -134,6 +145,7 @@ export async function failUndispatchedDocumentProcessing({ .set({ processingStatus: 'failed', processingError: error, + processingDeferredUntil: null, processingCompletedAt: now, }) .where( @@ -141,6 +153,10 @@ export async function failUndispatchedDocumentProcessing({ eq(document.id, documentId), eq(document.knowledgeBaseId, knowledgeBaseId), eq(document.processingStatus, 'pending'), + eq(document.userExcluded, false), + eq(document.processingQueueToken, processingQueueToken), + isNull(document.processingQueuedAt), + isNull(document.archivedAt), isNull(document.deletedAt) ) ) @@ -189,6 +205,7 @@ export async function recordUndispatchedDocumentFailure({ await failUndispatchedDocumentProcessing({ documentId, knowledgeBaseId, + processingQueueToken: requestId, error: truncate(failureMessage, DISPATCH_FAILURE_MESSAGE_MAX_LENGTH), }) } catch (markError) { diff --git a/apps/sim/lib/knowledge/documents/processing-dispatch.test.ts b/apps/sim/lib/knowledge/documents/processing-dispatch.test.ts new file mode 100644 index 00000000000..8a625c2ca93 --- /dev/null +++ b/apps/sim/lib/knowledge/documents/processing-dispatch.test.ts @@ -0,0 +1,97 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + processDocumentsWithQueue: vi.fn(), + recordUndispatchedDocumentFailure: vi.fn(), +})) + +vi.mock('@/lib/knowledge/documents/service', () => ({ + processDocumentsWithQueue: mocks.processDocumentsWithQueue, +})) + +vi.mock('@/lib/knowledge/documents/processing-claim', () => ({ + recordUndispatchedDocumentFailure: mocks.recordUndispatchedDocumentFailure, +})) + +import { dispatchDocumentProcessing } from '@/lib/knowledge/documents/processing-dispatch' + +const DOCUMENTS = [ + { + documentId: 'document-1', + filename: 'one.txt', + fileUrl: 'https://example.com/one.txt', + fileSize: 3, + mimeType: 'text/plain', + }, + { + documentId: 'document-2', + filename: 'two.txt', + fileUrl: 'https://example.com/two.txt', + fileSize: 3, + mimeType: 'text/plain', + }, +] + +describe('dispatchDocumentProcessing', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('records only documents whose returned dispatch outcome failed', async () => { + mocks.processDocumentsWithQueue.mockResolvedValueOnce({ + requested: 2, + accepted: 1, + failed: 1, + failedDocumentIds: ['document-2'], + }) + + await dispatchDocumentProcessing({ + documents: DOCUMENTS, + knowledgeBaseId: 'knowledge-base-1', + processingOptions: {}, + requestId: 'request-1', + billingAttribution: undefined, + }) + + expect(mocks.recordUndispatchedDocumentFailure).toHaveBeenCalledTimes(1) + expect(mocks.recordUndispatchedDocumentFailure).toHaveBeenCalledWith({ + documentId: 'document-2', + knowledgeBaseId: 'knowledge-base-1', + failureMessage: 'Document processing dispatch was not accepted', + requestId: 'request-1', + }) + }) + + it('does not turn a partial failure-recording error into a total dispatch failure', async () => { + mocks.processDocumentsWithQueue.mockResolvedValueOnce({ + requested: 2, + accepted: 0, + failed: 2, + failedDocumentIds: ['document-1', 'document-2'], + }) + mocks.recordUndispatchedDocumentFailure.mockRejectedValueOnce(new Error('database unavailable')) + + await expect( + dispatchDocumentProcessing({ + documents: DOCUMENTS, + knowledgeBaseId: 'knowledge-base-1', + processingOptions: {}, + requestId: 'request-1', + billingAttribution: undefined, + }) + ).resolves.toBeUndefined() + + expect(mocks.recordUndispatchedDocumentFailure).toHaveBeenCalledTimes(2) + expect(mocks.recordUndispatchedDocumentFailure).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ documentId: 'document-1' }) + ) + expect(mocks.recordUndispatchedDocumentFailure).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ documentId: 'document-2' }) + ) + }) +}) diff --git a/apps/sim/lib/knowledge/documents/processing-dispatch.ts b/apps/sim/lib/knowledge/documents/processing-dispatch.ts index ca65ab815c9..4f3fb6f235e 100644 --- a/apps/sim/lib/knowledge/documents/processing-dispatch.ts +++ b/apps/sim/lib/knowledge/documents/processing-dispatch.ts @@ -1,3 +1,4 @@ +import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import type { BillingAttributionSnapshot } from '@/lib/billing/core/billing-attribution' import { recordUndispatchedDocumentFailure } from '@/lib/knowledge/documents/processing-claim' @@ -15,6 +16,37 @@ interface DispatchDocumentProcessingParams { billingAttribution: BillingAttributionSnapshot | undefined } +const logger = createLogger('DocumentProcessingDispatch') + +async function recordDispatchFailures({ + documentIds, + knowledgeBaseId, + failureMessage, + requestId, +}: { + documentIds: string[] + knowledgeBaseId: string + failureMessage: string + requestId: string +}): Promise { + for (const documentId of documentIds) { + try { + await recordUndispatchedDocumentFailure({ + documentId, + knowledgeBaseId, + failureMessage, + requestId, + }) + } catch (error) { + logger.error('Failed to record an undispatched knowledge document', { + documentId, + knowledgeBaseId, + error: getErrorMessage(error), + }) + } + } +} + /** * Dispatches document processing and records the failure against every document * it stranded, rather than only logging it. @@ -37,23 +69,27 @@ export async function dispatchDocumentProcessing({ }: DispatchDocumentProcessingParams): Promise { if (documents.length === 0) return + let failedDocumentIds: string[] + let failureMessage: string try { - await processDocumentsWithQueue( + const dispatch = await processDocumentsWithQueue( documents, knowledgeBaseId, processingOptions, requestId, billingAttribution ) + failedDocumentIds = dispatch.failedDocumentIds + failureMessage = 'Document processing dispatch was not accepted' } catch (error) { - const failureMessage = getErrorMessage(error, 'Document processing dispatch failed') - for (const doc of documents) { - await recordUndispatchedDocumentFailure({ - documentId: doc.documentId, - knowledgeBaseId, - failureMessage, - requestId, - }) - } + failedDocumentIds = documents.map((document) => document.documentId) + failureMessage = getErrorMessage(error, 'Document processing dispatch failed') } + + await recordDispatchFailures({ + documentIds: failedDocumentIds, + knowledgeBaseId, + failureMessage, + requestId, + }) } diff --git a/apps/sim/lib/knowledge/documents/processing-outbox-handler.test.ts b/apps/sim/lib/knowledge/documents/processing-outbox-handler.test.ts index 9e095e9243a..343402fa360 100644 --- a/apps/sim/lib/knowledge/documents/processing-outbox-handler.test.ts +++ b/apps/sim/lib/knowledge/documents/processing-outbox-handler.test.ts @@ -75,7 +75,12 @@ describe('knowledge document processing outbox handler', () => { beforeEach(() => { vi.clearAllMocks() mocks.getKnowledgeDocument.mockResolvedValue(DOCUMENT) - mocks.processDocumentsWithQueue.mockResolvedValue(undefined) + mocks.processDocumentsWithQueue.mockResolvedValue({ + requested: 1, + accepted: 1, + failed: 0, + failedDocumentIds: [], + }) mocks.reclaimStaleDocumentProcessingClaim.mockResolvedValue(false) }) @@ -171,6 +176,19 @@ describe('knowledge document processing outbox handler', () => { await expect(handler()(PAYLOAD, createContext())).rejects.toBe(failure) }) + it('keeps the event retryable when dispatch returns a zero-acceptance failure', async () => { + mocks.processDocumentsWithQueue.mockResolvedValueOnce({ + requested: 1, + accepted: 0, + failed: 1, + failedDocumentIds: ['document-1'], + }) + + await expect(handler()(PAYLOAD, createContext())).rejects.toThrow( + 'processing dispatch was not accepted' + ) + }) + it('fails fast on malformed durable processing options', async () => { await expect( handler()({ ...PAYLOAD, processingOptions: { unsupported: true } }, createContext()) diff --git a/apps/sim/lib/knowledge/documents/processing-outbox-handler.ts b/apps/sim/lib/knowledge/documents/processing-outbox-handler.ts index e148de05d12..c008680600c 100644 --- a/apps/sim/lib/knowledge/documents/processing-outbox-handler.ts +++ b/apps/sim/lib/knowledge/documents/processing-outbox-handler.ts @@ -72,7 +72,7 @@ const processKnowledgeDocument: OutboxHandler = async (rawPayload, cont } context.signal.throwIfAborted() - await processDocumentsWithQueue( + const dispatch = await processDocumentsWithQueue( [ { documentId: document.id, @@ -87,6 +87,9 @@ const processKnowledgeDocument: OutboxHandler = async (rawPayload, cont context.eventId, payload.billingAttribution ) + if (dispatch.failed > 0 || dispatch.accepted !== 1) { + throw new Error(`Knowledge document ${document.id} processing dispatch was not accepted`) + } } export const knowledgeDocumentProcessingOutboxHandlers = { diff --git a/apps/sim/lib/knowledge/documents/processing-payload.ts b/apps/sim/lib/knowledge/documents/processing-payload.ts index fd6a6eb758a..0f01c55dfb1 100644 --- a/apps/sim/lib/knowledge/documents/processing-payload.ts +++ b/apps/sim/lib/knowledge/documents/processing-payload.ts @@ -18,6 +18,14 @@ export interface DocumentProcessingPayloadBase { lang?: string } requestId: string + /** Opaque queue generation. Absent only on payloads created before token rollout. */ + processingQueueToken?: string + /** Whether this payload's admission incremented the durable attempt budget. */ + chargedAtDispatch?: boolean + /** Exact queue-generation stamp this task is allowed to claim. */ + processingQueuedAt?: string + /** Number of durable quota continuations already scheduled for this indexing pass. */ + quotaRetryCount?: number } export interface WorkspaceDocumentProcessingBillingContext { @@ -139,6 +147,34 @@ export function assertDocumentProcessingPayload(value: unknown): DocumentProcess ) { throw new Error('Document processing payload is missing an identifier') } + if ( + value.processingQueueToken !== undefined && + (!isNonEmptyString(value.processingQueueToken) || + value.processingQueueToken !== value.requestId) + ) { + throw new Error('Document processing queue token is invalid') + } + if (value.processingQueueToken !== undefined && !isNonEmptyString(value.processingQueuedAt)) { + throw new Error('Document processing payload is missing its queue stamp') + } + if (value.chargedAtDispatch !== undefined && typeof value.chargedAtDispatch !== 'boolean') { + throw new Error('Document processing dispatch charge marker is invalid') + } + if (value.chargedAtDispatch !== undefined && value.processingQueueToken === undefined) { + throw new Error('Document processing dispatch charge marker requires a queue token') + } + if (value.processingQueuedAt !== undefined) { + if (!isNonEmptyString(value.processingQueuedAt)) { + throw new Error('Document processing queue stamp is invalid') + } + const processingQueuedAt = new Date(value.processingQueuedAt) + if ( + Number.isNaN(processingQueuedAt.getTime()) || + processingQueuedAt.toISOString() !== value.processingQueuedAt + ) { + throw new Error('Document processing queue stamp is invalid') + } + } if (!isRecordLike(value.docData)) { throw new Error('Document processing payload is missing document data') } @@ -156,6 +192,14 @@ export function assertDocumentProcessingPayload(value: unknown): DocumentProcess if (!isRecordLike(value.processingOptions)) { throw new Error('Document processing payload is missing processing options') } + if ( + value.quotaRetryCount !== undefined && + (typeof value.quotaRetryCount !== 'number' || + !Number.isSafeInteger(value.quotaRetryCount) || + value.quotaRetryCount < 0) + ) { + throw new Error('Document processing quota retry count is invalid') + } const processingOptions = value.processingOptions if ( (processingOptions.recipe !== undefined && typeof processingOptions.recipe !== 'string') || @@ -179,6 +223,16 @@ export function assertDocumentProcessingPayload(value: unknown): DocumentProcess ...(processingOptions.lang !== undefined ? { lang: processingOptions.lang } : {}), }, requestId: value.requestId, + ...(value.processingQueueToken !== undefined + ? { processingQueueToken: value.processingQueueToken } + : {}), + ...(value.chargedAtDispatch !== undefined + ? { chargedAtDispatch: value.chargedAtDispatch } + : {}), + ...(value.processingQueuedAt !== undefined + ? { processingQueuedAt: value.processingQueuedAt } + : {}), + ...(value.quotaRetryCount !== undefined ? { quotaRetryCount: value.quotaRetryCount } : {}), ...billingContext, } } diff --git a/apps/sim/lib/knowledge/documents/processing-queue.test.ts b/apps/sim/lib/knowledge/documents/processing-queue.test.ts index b6894323073..9e5fe8dda47 100644 --- a/apps/sim/lib/knowledge/documents/processing-queue.test.ts +++ b/apps/sim/lib/knowledge/documents/processing-queue.test.ts @@ -4,8 +4,13 @@ import { dbChainMockFns, defaultMockEnv, + flattenMockConditions, + hasMockCondition, + type MockCondition, + queueTableRows, resetDbChainMock, resetEnvFlagsMock, + schemaMock, setEnvFlags, } from '@sim/testing' import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' @@ -15,9 +20,12 @@ import { markInsideTriggerRun, resetInsideTriggerRunForTests, } from '@/lib/core/config/trigger-runtime' +import { DOCUMENT_PROCESSING_STALE_THRESHOLD_MS } from '@/lib/knowledge/documents/processing-timeouts.server' +import { QUEUED_DISPATCH_GRACE_MS } from '@/lib/knowledge/documents/types' -const { mockBatchTrigger } = vi.hoisted(() => ({ +const { mockBatchTrigger, mockResolveTriggerRegion } = vi.hoisted(() => ({ mockBatchTrigger: vi.fn(), + mockResolveTriggerRegion: vi.fn().mockResolvedValue('us-east-1'), })) vi.mock('@trigger.dev/sdk', () => ({ @@ -26,7 +34,7 @@ vi.mock('@trigger.dev/sdk', () => ({ }, })) vi.mock('@/lib/core/async-jobs/region', () => ({ - resolveTriggerRegion: vi.fn().mockResolvedValue('us-east-1'), + resolveTriggerRegion: mockResolveTriggerRegion, })) /** * Under `isolate: false` the shared `@/lib/knowledge/embeddings` / @@ -80,6 +88,7 @@ describe('processDocumentsWithQueue billing attribution', () => { // stub every worker would read as 'already completed' and return early. dbChainMockFns.returning.mockResolvedValue([{ id: 'document-1' }]) mockBatchTrigger.mockResolvedValue({ batchId: 'batch-1' }) + mockResolveTriggerRegion.mockResolvedValue('us-east-1') for (const key of Object.keys(env)) { delete (env as Record)[key] } @@ -100,6 +109,12 @@ describe('processDocumentsWithQueue billing attribution', () => { ) const jobs = mockBatchTrigger.mock.calls[0][1] + const queueWrite = dbChainMockFns.set.mock.calls.find( + (call) => (call[0] as Record | undefined)?.processingQueuedAt instanceof Date + ) + expect(queueWrite).toBeDefined() + const processingQueuedAt = (queueWrite?.[0] as Record) + .processingQueuedAt as Date expect(structuredClone(jobs[0].payload)).toEqual({ knowledgeBaseId: 'knowledge-base-1', documentId: 'document-1', @@ -111,11 +126,54 @@ describe('processDocumentsWithQueue billing attribution', () => { }, processingOptions: {}, requestId: 'request-1', + processingQueueToken: 'request-1', + chargedAtDispatch: true, + processingQueuedAt: processingQueuedAt.toISOString(), billingScope: 'workspace', actorUserId: 'external-admin', workspaceId: 'workspace-1', billingAttribution: BILLING_ATTRIBUTION, }) + + const freshAdmissionGuard = dbChainMockFns.where.mock.calls.find( + (call) => + hasMockCondition( + call[0], + (node: MockCondition) => + node.type === 'eq' && + node.left === schemaMock.document.processingStatus && + node.right === 'pending' + ) && + hasMockCondition( + call[0], + (node: MockCondition) => + node.type === 'isNull' && node.column === schemaMock.document.processingQueuedAt + ) + )?.[0] + expect(freshAdmissionGuard).toBeDefined() + expect( + hasMockCondition( + freshAdmissionGuard, + (node: MockCondition) => + node.type === 'eq' && + node.left === schemaMock.document.processingStatus && + node.right === 'pending' + ) + ).toBe(true) + expect( + hasMockCondition( + freshAdmissionGuard, + (node: MockCondition) => + node.type === 'isNull' && node.column === schemaMock.document.processingQueueToken + ) + ).toBe(false) + expect( + hasMockCondition( + freshAdmissionGuard, + (node: MockCondition) => + node.type === 'isNull' && node.column === schemaMock.document.processingQueuedAt + ) + ).toBe(true) }) it('rejects missing workspace attribution without enqueueing', async () => { @@ -127,6 +185,11 @@ describe('processDocumentsWithQueue billing attribution', () => { processDocumentsWithQueue([DOCUMENT], 'knowledge-base-1', {}, 'request-1', undefined) ).rejects.toThrow('Workspace document processing requires a billing attribution snapshot') expect(mockBatchTrigger).not.toHaveBeenCalled() + const withdrawal = dbChainMockFns.set.mock.calls.find( + (call) => (call[0] as Record | undefined)?.processingQueuedAt === null + )?.[0] as Record | undefined + expect(withdrawal).toBeDefined() + expect(withdrawal).not.toHaveProperty('processingQueueToken') }) it('rejects mismatched workspace attribution without enqueueing', async () => { @@ -168,6 +231,68 @@ describe('processDocumentsWithQueue billing attribution', () => { * own runtime, so the marker has to beat both environment conjuncts. */ describe('processDocumentsWithQueue dispatch backend', () => { + function guardForResumeWrite(): unknown { + const setIndex = dbChainMockFns.set.mock.calls.findIndex( + (call) => + (call[0] as Record | undefined)?.processingQueueToken === 'request-1' && + !('processingQueuedAt' in ((call[0] as Record | undefined) ?? {})) + ) + 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] + } + + function resumeAlternatives(guard: unknown): MockCondition[] { + const alternatives = flattenMockConditions(guard).find( + (node) => + node.type === 'or' && + (node.conditions as MockCondition[]).some((condition) => + hasMockCondition( + condition, + (nested) => + nested.type === 'eq' && + nested.left === schemaMock.document.processingQueueToken && + nested.right === 'request-1' + ) + ) && + (node.conditions as MockCondition[]).some((condition) => + hasMockCondition( + condition, + (nested) => + nested.type === 'isNull' && nested.column === schemaMock.document.processingQueueToken + ) + ) + )?.conditions + expect(alternatives).toBeDefined() + expect(alternatives).toHaveLength(2) + const conditions = alternatives as MockCondition[] + expect( + conditions.filter((condition) => + hasMockCondition( + condition, + (node) => + node.type === 'eq' && + node.left === schemaMock.document.processingQueueToken && + node.right === 'request-1' + ) + ) + ).toHaveLength(1) + expect( + conditions.filter((condition) => + hasMockCondition( + condition, + (node) => + node.type === 'isNull' && node.column === schemaMock.document.processingQueueToken + ) + ) + ).toHaveLength(1) + return conditions + } + beforeEach(() => { vi.clearAllMocks() resetDbChainMock() @@ -176,6 +301,7 @@ describe('processDocumentsWithQueue dispatch backend', () => { dbChainMockFns.returning.mockResolvedValue([{ id: 'document-1' }]) resetInsideTriggerRunForTests() mockBatchTrigger.mockResolvedValue({ batchId: 'batch-1' }) + mockResolveTriggerRegion.mockResolvedValue('us-east-1') for (const key of Object.keys(env)) { delete (env as Record)[key] } @@ -187,6 +313,7 @@ describe('processDocumentsWithQueue dispatch backend', () => { }) afterEach(() => { + vi.useRealTimers() resetInsideTriggerRunForTests() setEnvFlags({ isTriggerDevEnabled: true }) }) @@ -206,6 +333,443 @@ describe('processDocumentsWithQueue dispatch backend', () => { expect(mockBatchTrigger).toHaveBeenCalledTimes(1) }) + it('returns acceptance separately from eventual child completion', async () => { + markInsideTriggerRun() + + const result = await processDocumentsWithQueue( + [DOCUMENT], + 'knowledge-base-1', + {}, + 'request-1', + BILLING_ATTRIBUTION + ) + + expect(result).toEqual({ requested: 1, accepted: 1, failed: 0, failedDocumentIds: [] }) + expect(dbChainMockFns.set).toHaveBeenCalledWith( + expect.objectContaining({ + processingQueueToken: 'request-1', + processingDeferredUntil: null, + }) + ) + }) + + it('does not dispatch when a different request owns the queue generation', async () => { + markInsideTriggerRun() + dbChainMockFns.returning.mockResolvedValueOnce([]).mockResolvedValueOnce([]) + queueTableRows(schemaMock.document, [{ id: 'document-1' }]) + + const result = await processDocumentsWithQueue( + [DOCUMENT], + 'knowledge-base-1', + {}, + 'request-1', + BILLING_ATTRIBUTION + ) + + expect(result).toEqual({ requested: 1, accepted: 1, failed: 0, failedDocumentIds: [] }) + expect(mockBatchTrigger).not.toHaveBeenCalled() + }) + + it('does not report a failed row owned by an old queue token as accepted', async () => { + vi.useFakeTimers() + const now = new Date('2026-08-25T06:00:00.000Z') + vi.setSystemTime(now) + markInsideTriggerRun() + dbChainMockFns.returning.mockResolvedValueOnce([]).mockResolvedValueOnce([]) + queueTableRows(schemaMock.document, []) + + const result = await processDocumentsWithQueue( + [DOCUMENT], + 'knowledge-base-1', + {}, + 'request-1', + BILLING_ATTRIBUTION + ) + + expect(result).toEqual({ + requested: 1, + accepted: 0, + failed: 1, + failedDocumentIds: ['document-1'], + }) + expect(mockBatchTrigger).not.toHaveBeenCalled() + + const acceptedWithoutDispatchGuard = dbChainMockFns.where.mock.calls.find((call) => + flattenMockConditions(call[0]).some( + (node: MockCondition) => + node.type === 'or' && + (node.conditions as MockCondition[]).some( + (condition) => + condition.type === 'eq' && + condition.left === schemaMock.document.processingStatus && + condition.right === 'completed' + ) + ) + )?.[0] + expect(acceptedWithoutDispatchGuard).toBeDefined() + const acceptedStatusGuard = flattenMockConditions(acceptedWithoutDispatchGuard).find( + (node: MockCondition) => node.type === 'or' + ) + expect(acceptedStatusGuard).toBeDefined() + const acceptedStatuses = acceptedStatusGuard?.conditions as MockCondition[] + expect(acceptedStatuses).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: 'eq', + left: schemaMock.document.processingStatus, + right: 'completed', + }), + ]) + ) + expect(acceptedStatuses).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: 'eq', + left: schemaMock.document.processingStatus, + right: 'failed', + }), + ]) + ) + const pendingWithQueueState = acceptedStatuses.find( + (condition) => + condition.type === 'and' && + hasMockCondition( + condition, + (node: MockCondition) => + node.type === 'eq' && + node.left === schemaMock.document.processingStatus && + node.right === 'pending' + ) && + hasMockCondition( + condition, + (node: MockCondition) => + node.type === 'isNotNull' && node.column === schemaMock.document.processingQueuedAt + ) + ) + expect(pendingWithQueueState).toBeDefined() + expect( + hasMockCondition( + pendingWithQueueState, + (node: MockCondition) => + node.type === 'gte' && node.left === schemaMock.document.processingQueuedAt + ) + ).toBe(true) + const queuedFreshness = flattenMockConditions(pendingWithQueueState).find( + (node: MockCondition) => + node.type === 'gte' && node.left === schemaMock.document.processingQueuedAt + ) + expect(queuedFreshness?.right).toEqual(new Date(now.getTime() - QUEUED_DISPATCH_GRACE_MS)) + const liveProcessingState = acceptedStatuses.find( + (condition) => + condition.type === 'and' && + hasMockCondition( + condition, + (node: MockCondition) => + node.type === 'eq' && + node.left === schemaMock.document.processingStatus && + node.right === 'processing' + ) + ) + expect(liveProcessingState).toBeDefined() + expect( + hasMockCondition( + liveProcessingState, + (node: MockCondition) => + node.type === 'isNotNull' && node.column === schemaMock.document.processingStartedAt + ) + ).toBe(true) + const processingFreshness = flattenMockConditions(liveProcessingState).find( + (node: MockCondition) => + node.type === 'gte' && node.left === schemaMock.document.processingStartedAt + ) + expect(processingFreshness?.right).toEqual( + new Date(now.getTime() - DOCUMENT_PROCESSING_STALE_THRESHOLD_MS) + ) + expect( + hasMockCondition( + liveProcessingState, + (node: MockCondition) => + node.type === 'gte' && node.left === schemaMock.document.processingStartedAt + ) + ).toBe(true) + }) + + it('resumes the same outbox request with its original stamp and no new charge', async () => { + markInsideTriggerRun() + const originalQueuedAt = new Date('2026-08-24T22:00:00.000Z') + dbChainMockFns.returning + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([{ id: 'document-1', processingQueuedAt: originalQueuedAt }]) + + const result = await processDocumentsWithQueue( + [DOCUMENT], + 'knowledge-base-1', + {}, + 'request-1', + BILLING_ATTRIBUTION + ) + + expect(result).toEqual({ requested: 1, accepted: 1, failed: 0, failedDocumentIds: [] }) + const payload = mockBatchTrigger.mock.calls[0][1][0].payload + expect(payload).toMatchObject({ + processingQueueToken: 'request-1', + processingQueuedAt: originalQueuedAt.toISOString(), + chargedAtDispatch: false, + }) + const resumeWrite = dbChainMockFns.set.mock.calls.find( + (call) => + (call[0] as Record | undefined)?.processingQueueToken === 'request-1' && + !('processingQueuedAt' in ((call[0] as Record | undefined) ?? {})) + ) + expect(resumeWrite?.[0]).not.toHaveProperty('processingAttempts') + + const resumeGuard = guardForResumeWrite() + expect( + hasMockCondition( + resumeGuard, + (node: MockCondition) => + node.type === 'isNull' && node.column === schemaMock.document.processingDeferredUntil + ) + ).toBe(true) + const sameTokenBranch = resumeAlternatives(resumeGuard).find((condition) => + hasMockCondition( + condition, + (node: MockCondition) => + node.type === 'eq' && + node.left === schemaMock.document.processingQueueToken && + node.right === 'request-1' + ) + ) + expect(sameTokenBranch).toBeDefined() + expect( + hasMockCondition( + resumeGuard, + (node: MockCondition) => + node.type === 'isNotNull' && node.column === schemaMock.document.processingQueuedAt + ) + ).toBe(true) + const statusGuard = flattenMockConditions(sameTokenBranch).find( + (node: MockCondition) => node.type === 'or' + ) + expect(statusGuard).toBeDefined() + const statusConditions = statusGuard?.conditions as MockCondition[] + expect(statusConditions).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: 'eq', + left: schemaMock.document.processingStatus, + right: 'pending', + }), + expect.objectContaining({ + type: 'eq', + left: schemaMock.document.processingStatus, + right: 'failed', + }), + ]) + ) + }) + + it('treats a recent legacy queued-at-only row as live without redispatching it', async () => { + vi.useFakeTimers() + const now = new Date('2026-08-24T22:00:00.000Z') + vi.setSystemTime(now) + markInsideTriggerRun() + dbChainMockFns.returning.mockResolvedValueOnce([]).mockResolvedValueOnce([]) + queueTableRows(schemaMock.document, [{ id: 'document-1' }]) + + const result = await processDocumentsWithQueue( + [DOCUMENT], + 'knowledge-base-1', + {}, + 'request-1', + BILLING_ATTRIBUTION + ) + + expect(result).toEqual({ requested: 1, accepted: 1, failed: 0, failedDocumentIds: [] }) + expect(mockBatchTrigger).not.toHaveBeenCalled() + const resumeWrite = dbChainMockFns.set.mock.calls.find( + (call) => + (call[0] as Record | undefined)?.processingQueueToken === 'request-1' && + !('processingQueuedAt' in ((call[0] as Record | undefined) ?? {})) + ) + expect(resumeWrite).toBeDefined() + const resumeGuard = guardForResumeWrite() + const legacyBranch = resumeAlternatives(resumeGuard).find( + (condition) => + hasMockCondition( + condition, + (node: MockCondition) => + node.type === 'isNull' && node.column === schemaMock.document.processingQueueToken + ) && + hasMockCondition( + condition, + (node: MockCondition) => + node.type === 'lt' && node.left === schemaMock.document.processingQueuedAt + ) + ) + expect(legacyBranch).toBeDefined() + const cutoff = flattenMockConditions(legacyBranch).find( + (node: MockCondition) => + node.type === 'lt' && node.left === schemaMock.document.processingQueuedAt + ) + expect(cutoff?.right).toEqual(new Date(now.getTime() - QUEUED_DISPATCH_GRACE_MS)) + }) + + it('CAS-adopts a stale legacy queued-at-only row without charging again', async () => { + markInsideTriggerRun() + const legacyQueuedAt = new Date('2020-01-01T00:00:00.000Z') + dbChainMockFns.returning + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([{ id: 'document-1', processingQueuedAt: legacyQueuedAt }]) + + const result = await processDocumentsWithQueue( + [DOCUMENT], + 'knowledge-base-1', + {}, + 'request-1', + BILLING_ATTRIBUTION + ) + + expect(result).toEqual({ requested: 1, accepted: 1, failed: 0, failedDocumentIds: [] }) + expect(mockBatchTrigger.mock.calls[0][1][0].payload).toMatchObject({ + processingQueueToken: 'request-1', + processingQueuedAt: legacyQueuedAt.toISOString(), + chargedAtDispatch: false, + }) + + const legacyAdoptionGuard = guardForResumeWrite() + const legacyBranch = resumeAlternatives(legacyAdoptionGuard).find( + (condition) => + hasMockCondition( + condition, + (node: MockCondition) => + node.type === 'isNull' && node.column === schemaMock.document.processingQueueToken + ) && + hasMockCondition( + condition, + (node: MockCondition) => + node.type === 'lt' && node.left === schemaMock.document.processingQueuedAt + ) + ) + expect(legacyBranch).toBeDefined() + expect( + hasMockCondition( + legacyAdoptionGuard, + (node: MockCondition) => + node.type === 'eq' && + node.left === schemaMock.document.knowledgeBaseId && + node.right === 'knowledge-base-1' + ) + ).toBe(true) + for (const column of [schemaMock.document.archivedAt, schemaMock.document.deletedAt]) { + expect( + hasMockCondition( + legacyAdoptionGuard, + (node: MockCondition) => node.type === 'isNull' && node.column === column + ) + ).toBe(true) + } + const adoptionWrite = dbChainMockFns.set.mock.calls.find( + (call) => + (call[0] as Record | undefined)?.processingQueueToken === 'request-1' && + !('processingQueuedAt' in ((call[0] as Record | undefined) ?? {})) + ) + expect(adoptionWrite?.[0]).not.toHaveProperty('processingAttempts') + }) + + it('keeps a pre-claim same-request fallback failure retryable without clearing its stamp', async () => { + markInsideTriggerRun() + const originalQueuedAt = new Date('2026-08-24T22:00:00.000Z') + dbChainMockFns.returning + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([{ id: 'document-1', processingQueuedAt: originalQueuedAt }]) + dbChainMockFns.limit + .mockResolvedValueOnce([{ userId: 'knowledge-owner', workspaceId: 'workspace-1' }]) + .mockRejectedValueOnce(new Error('direct fallback unavailable')) + mockBatchTrigger.mockRejectedValueOnce(new Error('trigger unavailable')) + + await expect( + processDocumentsWithQueue( + [DOCUMENT], + 'knowledge-base-1', + {}, + 'request-1', + BILLING_ATTRIBUTION + ) + ).rejects.toThrow('document processing dispatches failed') + + expect( + dbChainMockFns.set.mock.calls.some( + (call) => (call[0] as Record | undefined)?.processingQueueToken === null + ) + ).toBe(false) + }) + + it('reports a pre-claim direct fallback failure after a partial Trigger enqueue', async () => { + markInsideTriggerRun() + const originalQueuedAt = new Date('2026-08-24T22:00:00.000Z') + const documents = Array.from({ length: 1001 }, (_, index) => ({ + ...DOCUMENT, + documentId: `document-${index}`, + filename: `document-${index}.txt`, + })) + dbChainMockFns.returning + .mockResolvedValueOnce([]) + .mockResolvedValueOnce( + documents.map((doc) => ({ id: doc.documentId, processingQueuedAt: originalQueuedAt })) + ) + dbChainMockFns.limit + .mockResolvedValueOnce([{ userId: 'knowledge-owner', workspaceId: 'workspace-1' }]) + .mockRejectedValueOnce(new Error('direct fallback unavailable')) + mockBatchTrigger + .mockResolvedValueOnce({ batchId: 'batch-1' }) + .mockRejectedValueOnce(new Error('second batch unavailable')) + + const result = await processDocumentsWithQueue( + documents, + 'knowledge-base-1', + {}, + 'request-1', + BILLING_ATTRIBUTION + ) + + expect(result).toEqual({ + requested: 1001, + accepted: 1000, + failed: 1, + failedDocumentIds: ['document-1000'], + }) + }) + + it('deduplicates document IDs while preserving first-seen dispatch order', async () => { + markInsideTriggerRun() + const secondDocument = { ...DOCUMENT, documentId: 'document-2', filename: 'second.txt' } + dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'document-1' }, { id: 'document-2' }]) + + const result = await processDocumentsWithQueue( + [DOCUMENT, DOCUMENT, secondDocument], + 'knowledge-base-1', + {}, + 'request-1', + BILLING_ATTRIBUTION + ) + + expect(result).toEqual({ requested: 2, accepted: 2, failed: 0, failedDocumentIds: [] }) + expect(mockBatchTrigger.mock.calls[0][1].map((job) => job.payload.documentId)).toEqual([ + 'document-1', + 'document-2', + ]) + }) + + it('returns an empty dispatch summary without resolving billing context', async () => { + await expect( + processDocumentsWithQueue([], 'missing-knowledge-base', {}, 'request-1', undefined) + ).resolves.toEqual({ requested: 0, accepted: 0, failed: 0, failedDocumentIds: [] }) + + expect(mockBatchTrigger).not.toHaveBeenCalled() + expect(dbChainMockFns.limit).not.toHaveBeenCalled() + }) + it('dispatches via Trigger.dev inside a run when only the secret key is missing', async () => { setEnvFlags({ isTriggerDevEnabled: true }) markInsideTriggerRun() @@ -221,8 +785,50 @@ describe('processDocumentsWithQueue dispatch backend', () => { expect(mockBatchTrigger).toHaveBeenCalledTimes(1) }) - it('does not dispatch via Trigger.dev outside a run when the secret key is missing', async () => { + it('uses the direct fallback outside a run when the secret key is missing', async () => { + setEnvFlags({ isTriggerDevEnabled: true }) + + await expect( + processDocumentsWithQueue( + [DOCUMENT], + 'knowledge-base-1', + {}, + 'request-1', + BILLING_ATTRIBUTION + ) + ).resolves.toEqual({ requested: 1, accepted: 1, failed: 0, failedDocumentIds: [] }) + + expect(mockBatchTrigger).not.toHaveBeenCalled() + expect(dbChainMockFns.set).toHaveBeenCalledWith( + expect.objectContaining({ processingStatus: 'processing' }) + ) + expect( + dbChainMockFns.set.mock.calls.some( + (call) => + (call[0] as Record | undefined)?.processingQueuedAt === null && + 'processingAttempts' in ((call[0] as Record | undefined) ?? {}) + ) + ).toBe(false) + }) + + it('withdraws a direct dispatch whose guarded processing claim lost the race', async () => { setEnvFlags({ isTriggerDevEnabled: true }) + dbChainMockFns.returning.mockReset() + dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'document-1' }]).mockResolvedValueOnce([]) + dbChainMockFns.limit.mockReset() + dbChainMockFns.limit + .mockResolvedValueOnce([{ userId: 'knowledge-owner', workspaceId: 'workspace-1' }]) + .mockResolvedValueOnce([ + { + knowledgeBaseUserId: 'knowledge-owner', + workspaceId: 'workspace-1', + filename: DOCUMENT.filename, + fileUrl: DOCUMENT.fileUrl, + fileSize: DOCUMENT.fileSize, + mimeType: DOCUMENT.mimeType, + }, + ]) + .mockResolvedValueOnce([]) await expect( processDocumentsWithQueue( @@ -232,12 +838,119 @@ describe('processDocumentsWithQueue dispatch backend', () => { 'request-1', BILLING_ATTRIBUTION ) - ).rejects.toThrow() + ).rejects.toThrow('document processing dispatches failed') expect(mockBatchTrigger).not.toHaveBeenCalled() + expect( + dbChainMockFns.set.mock.calls.some( + (call) => + (call[0] as Record | undefined)?.processingQueuedAt === null && + 'processingAttempts' in ((call[0] as Record | undefined) ?? {}) + ) + ).toBe(true) + }) + + it('accepts an unclaimed direct dispatch only after revalidating live document state', async () => { + vi.useFakeTimers() + const now = new Date('2026-08-25T06:00:00.000Z') + vi.setSystemTime(now) + setEnvFlags({ isTriggerDevEnabled: true }) + dbChainMockFns.returning.mockReset() + dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'document-1' }]).mockResolvedValueOnce([]) + dbChainMockFns.limit.mockReset() + dbChainMockFns.limit + .mockResolvedValueOnce([{ userId: 'knowledge-owner', workspaceId: 'workspace-1' }]) + .mockResolvedValueOnce([ + { + knowledgeBaseUserId: 'knowledge-owner', + workspaceId: 'workspace-1', + filename: DOCUMENT.filename, + fileUrl: DOCUMENT.fileUrl, + fileSize: DOCUMENT.fileSize, + mimeType: DOCUMENT.mimeType, + }, + ]) + .mockResolvedValueOnce([{ id: 'document-1' }]) + + await expect( + processDocumentsWithQueue( + [DOCUMENT], + 'knowledge-base-1', + {}, + 'request-1', + BILLING_ATTRIBUTION + ) + ).resolves.toEqual({ requested: 1, accepted: 1, failed: 0, failedDocumentIds: [] }) + + const revalidationGuard = dbChainMockFns.where.mock.calls.find( + (call) => + hasMockCondition( + call[0], + (node: MockCondition) => + node.type === 'eq' && + node.left === schemaMock.document.id && + node.right === 'document-1' + ) && + flattenMockConditions(call[0]).some( + (node: MockCondition) => + node.type === 'or' && + (node.conditions as MockCondition[]).some( + (condition) => + condition.type === 'eq' && + condition.left === schemaMock.document.processingStatus && + condition.right === 'completed' + ) + ) + )?.[0] + expect(revalidationGuard).toBeDefined() + const acceptedStatusGuard = flattenMockConditions(revalidationGuard).find( + (node: MockCondition) => node.type === 'or' + ) + const acceptedStatuses = acceptedStatusGuard?.conditions as MockCondition[] + const pendingState = acceptedStatuses.find( + (condition) => + condition.type === 'and' && + hasMockCondition( + condition, + (node: MockCondition) => + node.type === 'eq' && + node.left === schemaMock.document.processingStatus && + node.right === 'pending' + ) + ) + const queuedFreshness = flattenMockConditions(pendingState).find( + (node: MockCondition) => + node.type === 'gte' && node.left === schemaMock.document.processingQueuedAt + ) + expect(queuedFreshness?.right).toEqual(new Date(now.getTime() - QUEUED_DISPATCH_GRACE_MS)) + const processingState = acceptedStatuses.find( + (condition) => + condition.type === 'and' && + hasMockCondition( + condition, + (node: MockCondition) => + node.type === 'eq' && + node.left === schemaMock.document.processingStatus && + node.right === 'processing' + ) + ) + const processingFreshness = flattenMockConditions(processingState).find( + (node: MockCondition) => + node.type === 'gte' && node.left === schemaMock.document.processingStartedAt + ) + expect(processingFreshness?.right).toEqual( + new Date(now.getTime() - DOCUMENT_PROCESSING_STALE_THRESHOLD_MS) + ) + expect( + dbChainMockFns.set.mock.calls.some( + (call) => + (call[0] as Record | undefined)?.processingQueuedAt === null && + 'processingAttempts' in ((call[0] as Record | undefined) ?? {}) + ) + ).toBe(false) }) - it('does not dispatch via Trigger.dev outside a run when the deployment flag is off', async () => { + it('uses the direct fallback outside a run when the deployment flag is off', async () => { setEnvFlags({ isTriggerDevEnabled: false }) Object.assign(env, { TRIGGER_SECRET_KEY: 'trigger-secret' }) @@ -249,7 +962,7 @@ describe('processDocumentsWithQueue dispatch backend', () => { 'request-1', BILLING_ATTRIBUTION ) - ).rejects.toThrow() + ).resolves.toEqual({ requested: 1, accepted: 1, failed: 0, failedDocumentIds: [] }) expect(mockBatchTrigger).not.toHaveBeenCalled() }) @@ -269,6 +982,7 @@ describe('processDocumentsWithQueue attempt refund', () => { vi.clearAllMocks() resetDbChainMock() dbChainMockFns.returning.mockResolvedValue([{ id: 'document-1' }]) + mockResolveTriggerRegion.mockResolvedValue('us-east-1') for (const key of Object.keys(env)) { delete (env as Record)[key] } @@ -279,7 +993,7 @@ describe('processDocumentsWithQueue attempt refund', () => { }) it('refunds the attempt in the same write that withdraws the queue stamp', async () => { - mockBatchTrigger.mockRejectedValue(new Error('trigger.dev region unavailable')) + mockResolveTriggerRegion.mockRejectedValueOnce(new Error('trigger.dev region unavailable')) await expect( processDocumentsWithQueue( @@ -289,7 +1003,7 @@ describe('processDocumentsWithQueue attempt refund', () => { 'request-1', BILLING_ATTRIBUTION ) - ).rejects.toThrow('document processing dispatches failed') + ).rejects.toThrow('trigger.dev region unavailable') const withdrawCall = dbChainMockFns.set.mock.calls.find( (call) => (call[0] as Record | undefined)?.processingQueuedAt === null @@ -297,6 +1011,7 @@ describe('processDocumentsWithQueue attempt refund', () => { expect(withdrawCall).toBeDefined() const values = withdrawCall?.[0] as Record + expect(values).not.toHaveProperty('processingQueueToken') const attempts = values.processingAttempts as { toSQL: () => { sql: string } } | undefined expect(attempts).toBeDefined() // Given back as a SQL decrement in the same guarded statement as the stamp, @@ -304,6 +1019,27 @@ describe('processDocumentsWithQueue attempt refund', () => { expect(attempts?.toSQL().sql).toContain('- 1') // Floored, so a refund can never drive the count below zero. expect(attempts?.toSQL().sql).toContain('GREATEST') + + const withdrawIndex = dbChainMockFns.set.mock.calls.findIndex( + (call) => call[0] === withdrawCall?.[0] + ) + const withdrawOrder = dbChainMockFns.set.mock.invocationCallOrder[withdrawIndex] + const whereIndex = dbChainMockFns.where.mock.invocationCallOrder.findIndex( + (order) => order > withdrawOrder + ) + const withdrawalGuard = dbChainMockFns.where.mock.calls[whereIndex]?.[0] + const queueWrite = dbChainMockFns.set.mock.calls.find( + (call) => (call[0] as Record | undefined)?.processingQueuedAt instanceof Date + )?.[0] as Record | undefined + expect( + hasMockCondition( + withdrawalGuard, + (node: MockCondition) => + node.type === 'eq' && + node.left === schemaMock.document.processingQueuedAt && + node.right === queueWrite?.processingQueuedAt + ) + ).toBe(true) }) it('leaves the attempt spent when a dispatch did get through', async () => { diff --git a/apps/sim/lib/knowledge/documents/processing-quota-continuation.ts b/apps/sim/lib/knowledge/documents/processing-quota-continuation.ts new file mode 100644 index 00000000000..30d7866c98b --- /dev/null +++ b/apps/sim/lib/knowledge/documents/processing-quota-continuation.ts @@ -0,0 +1,57 @@ +import { backoffWithJitter } from '@sim/utils/retry' +import { tasks } from '@trigger.dev/sdk' +import { resolveTriggerRegion } from '@/lib/core/async-jobs/region' +import { EMBEDDING_QUOTA_CIRCUIT_TTL_MS } from '@/lib/embeddings/quota-circuit' +import type { DocumentProcessingPayload } from '@/lib/knowledge/documents/processing-payload' + +const MAX_QUOTA_CONTINUATION_DELAY_MS = 6 * 60 * 60 * 1000 +export const MAX_QUOTA_CONTINUATION_ATTEMPTS = 8 + +/** Backs durable quota continuations off with jitter to a six-hour polling ceiling. */ +export function resolveQuotaContinuationDelayMs(quotaRetryCount: number): number { + return Math.min( + backoffWithJitter(Math.max(quotaRetryCount, 1), null, { + baseMs: EMBEDDING_QUOTA_CIRCUIT_TTL_MS, + maxMs: MAX_QUOTA_CONTINUATION_DELAY_MS, + }), + MAX_QUOTA_CONTINUATION_DELAY_MS + ) +} + +export function canScheduleDocumentProcessingQuotaContinuation( + payload: Pick +): boolean { + return (payload.quotaRetryCount ?? 0) < MAX_QUOTA_CONTINUATION_ATTEMPTS +} + +/** + * Hands quota-blocked work to a delayed run without changing its indexing-pass + * identity. The idempotency key makes concurrent direct and worker handoffs for + * the same continuation generation converge on one run. + */ +export async function scheduleDocumentProcessingQuotaContinuation( + payload: DocumentProcessingPayload +): Promise { + if (!canScheduleDocumentProcessingQuotaContinuation(payload)) { + throw new Error('Document processing quota continuation limit reached') + } + const quotaRetryCount = (payload.quotaRetryCount ?? 0) + 1 + const delayMs = resolveQuotaContinuationDelayMs(quotaRetryCount) + const region = await resolveTriggerRegion() + const deferredUntil = new Date(Date.now() + delayMs) + await tasks.trigger( + 'knowledge-process-document', + { + ...payload, + ...(payload.processingQueueToken ? { processingQueuedAt: deferredUntil.toISOString() } : {}), + quotaRetryCount, + }, + { + delay: deferredUntil, + idempotencyKey: `knowledge-quota-${payload.documentId}-${payload.requestId}-${quotaRetryCount}`, + tags: [`knowledgeBaseId:${payload.knowledgeBaseId}`, `documentId:${payload.documentId}`], + region, + } + ) + return deferredUntil +} diff --git a/apps/sim/lib/knowledge/documents/processing-timeouts.server.ts b/apps/sim/lib/knowledge/documents/processing-timeouts.server.ts new file mode 100644 index 00000000000..72a396a8cd6 --- /dev/null +++ b/apps/sim/lib/knowledge/documents/processing-timeouts.server.ts @@ -0,0 +1,14 @@ +import { env, envNumber } from '@/lib/core/config/env' +import { resolveStaleProcessingMinutes } from '@/lib/knowledge/documents/types' + +/** + * Config-aware horizon shared by every server path that decides whether an + * active document-processing run is abandoned. + */ +export const DOCUMENT_PROCESSING_STALE_THRESHOLD_MS = + resolveStaleProcessingMinutes( + envNumber(env.KB_CONFIG_MAX_DURATION, 600), + envNumber(env.KB_CONFIG_MAX_ATTEMPTS, 3) + ) * + 60 * + 1000 diff --git a/apps/sim/lib/knowledge/documents/retry-processing-grace.test.ts b/apps/sim/lib/knowledge/documents/retry-processing-grace.test.ts index 3cfda46389c..d7cb8abf547 100644 --- a/apps/sim/lib/knowledge/documents/retry-processing-grace.test.ts +++ b/apps/sim/lib/knowledge/documents/retry-processing-grace.test.ts @@ -208,6 +208,14 @@ describe('retryDocumentProcessing requeue guard', () => { node.values.join(',') === 'completed,failed' ) ).toBe(true) + const reset = dbChainMockFns.set.mock.calls.find( + (call) => (call[0] as Record | undefined)?.processingStatus === 'pending' + ) + expect(reset?.[0]).toMatchObject({ + processingQueuedAt: null, + processingQueueToken: null, + }) + expect(reset?.[0]).not.toHaveProperty('processingAttempts') }) it('also requeues a pending document whose dispatch is certainly lost', async () => { @@ -257,6 +265,23 @@ describe('retryDocumentProcessing requeue guard', () => { expect((fragment.values[2] as { value: Date }).value).toEqual( new Date(now.getTime() - QUEUED_DISPATCH_GRACE_MS) ) + expect( + hasBranch( + statusGuard(), + (node: MockCondition) => + node.type === 'isNull' && node.column === schemaMock.document.processingDeferredUntil + ) + ).toBe(true) + expect( + hasBranch( + statusGuard(), + (node: MockCondition) => + node.type === 'lt' && + node.left === schemaMock.document.processingDeferredUntil && + node.right instanceof Date && + node.right.getTime() === now.getTime() - QUEUED_DISPATCH_GRACE_MS + ) + ).toBe(true) } finally { vi.useRealTimers() } @@ -275,6 +300,8 @@ describe('retryDocumentProcessing requeue guard', () => { processingStatus: 'pending' as const, processingQueuedAt: null, processingStartedAt: null, + processingDeferredUntil: null, + processingCompletedAt: null, uploadedAt, } @@ -294,7 +321,8 @@ describe('retryDocumentProcessing requeue guard', () => { // No dispatch means no queue stamp was written either. expect( dbChainMockFns.set.mock.calls.some( - (call) => (call[0] as Record | undefined)?.processingQueuedAt !== undefined + (call) => + (call[0] as Record | undefined)?.processingQueuedAt instanceof Date ) ).toBe(false) }) @@ -373,4 +401,21 @@ describe('retryDocumentProcessing dispatch unwind', () => { expect(result.message).not.toContain('retry processing started') expect(result.status).toBe('failed') }) + + it('records and reports a returned zero-acceptance queue-admission result', async () => { + dbChainMockFns.returning + .mockResolvedValueOnce([{ id: 'doc-1' }]) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([]) + dbChainMockFns.limit.mockResolvedValue([{ userId: 'user-1', workspaceId: null }]) + + const result = await retryDocumentProcessing('kb-1', 'doc-1', DOC_DATA, 'req-1', undefined) + + expect(result).toMatchObject({ success: false, status: 'failed' }) + expect(result.message).toContain('was not accepted') + const failedWrite = dbChainMockFns.set.mock.calls.find( + (call) => (call[0] as Record | undefined)?.processingStatus === 'failed' + ) + expect(failedWrite).toBeDefined() + }) }) diff --git a/apps/sim/lib/knowledge/documents/secure-fetch.server.ts b/apps/sim/lib/knowledge/documents/secure-fetch.server.ts index ec70352dcd8..8c40b863542 100644 --- a/apps/sim/lib/knowledge/documents/secure-fetch.server.ts +++ b/apps/sim/lib/knowledge/documents/secure-fetch.server.ts @@ -8,6 +8,7 @@ import { type HTTPError, isRetryableError, type RetryOptions, + readBoundedHttpErrorBody, resolveRetryDelayMs, retryWithExponentialBackoff, } from '@/lib/knowledge/documents/utils' @@ -34,7 +35,6 @@ export async function secureFetchWithRetry( retryOptions: SecureFetchRetryOptions = {} ): Promise { const { allowHttp, timeout, maxResponseBytes, ...retry } = retryOptions - return retryWithExponentialBackoff(async () => { const response = await secureFetchWithValidation( url, @@ -56,12 +56,9 @@ export async function secureFetchWithRetry( * limit) use instead. */ if (!response.ok && isRetryableError({ status: response.status, headers: response.headers })) { - const errorText = await response.text() - const error: HTTPError = new Error( - `HTTP ${response.status}: ${response.statusText} - ${errorText}` - ) + const errorText = await readBoundedHttpErrorBody(response) + const error: HTTPError = new Error(`HTTP ${response.status} - ${errorText}`) error.status = response.status - error.statusText = response.statusText attachRetryHeaders(error, response.headers) const waitMs = resolveRetryDelayMs(response.headers) diff --git a/apps/sim/lib/knowledge/documents/service.ts b/apps/sim/lib/knowledge/documents/service.ts index a54673a3089..a8c22018b8a 100644 --- a/apps/sim/lib/knowledge/documents/service.ts +++ b/apps/sim/lib/knowledge/documents/service.ts @@ -20,9 +20,11 @@ import { desc, eq, getTableColumns, + gte, inArray, isNotNull, isNull, + lt, ne, or, type SQL, @@ -61,12 +63,25 @@ import { getCostMultiplier, isTriggerDevEnabled } from '@/lib/core/config/env-fl import { isInsideTriggerRun } from '@/lib/core/config/trigger-runtime' import { OrchestrationError } from '@/lib/core/orchestration/types' import { mapWithConcurrency } from '@/lib/core/utils/concurrency' +import { + EMBEDDING_QUOTA_EXHAUSTED_MESSAGE, + getEmbeddingAggregateItemLimit, + isEmbeddingQuotaExhaustion, +} from '@/lib/embeddings' import { type DurableSecretProvenance, durableSecretProvenanceFromRegistry, EXACT_EMPTY_DURABLE_SECRET_PROVENANCE, mergeDurableSecretProvenance, } from '@/lib/execution/durable-secret-provenance' +import { + assertDocumentChunkCountWithinLimit, + isPermanentDocumentProcessingError, + isUsageLimitDocumentProcessingError, + PermanentDocumentProcessingError, + toPermanentDocumentProcessingError, + UsageLimitDocumentProcessingError, +} from '@/lib/knowledge/documents/document-processing-error' import { processDocument } from '@/lib/knowledge/documents/document-processor' import { failStaleDocumentProcessingClaim, @@ -82,16 +97,19 @@ import { type DocumentProcessingPayload, hasDocumentProcessingBillingScope, } from '@/lib/knowledge/documents/processing-payload' +import { scheduleDocumentProcessingQuotaContinuation } from '@/lib/knowledge/documents/processing-quota-continuation' +import { DOCUMENT_PROCESSING_STALE_THRESHOLD_MS } from '@/lib/knowledge/documents/processing-timeouts.server' import { buildTagFilterCondition, type TagFilterCondition, } from '@/lib/knowledge/documents/tag-filter' import { type DocumentSortField, + MAX_PROCESSING_ATTEMPTS, QUEUED_DISPATCH_GRACE_MS, type SortOrder, } from '@/lib/knowledge/documents/types' -import { getEmbeddingModelInfo } from '@/lib/knowledge/embedding-models' +import { EMBEDDING_DIMENSIONS, getEmbeddingModelInfo } from '@/lib/knowledge/embedding-models' import { generateEmbeddings } from '@/lib/knowledge/embeddings' import { runWithKnowledgeModelInputProvenance } from '@/lib/knowledge/model-input-provenance' import { @@ -287,9 +305,11 @@ const TIMEOUTS = { const LARGE_DOC_CONFIG = { MAX_CHUNKS_PER_BATCH: 500, - MAX_EMBEDDING_BATCH: envNumber(env.KB_CONFIG_BATCH_SIZE, 2000), + MAX_EMBEDDING_BATCH: Math.min( + envNumber(env.KB_CONFIG_BATCH_SIZE, 2000, { min: 1, integer: true }), + getEmbeddingAggregateItemLimit(EMBEDDING_DIMENSIONS) + ), MAX_FILE_SIZE: 100 * 1024 * 1024, - MAX_CHUNKS_PER_DOCUMENT: 100000, } const HARD_DELETE_DOCUMENT_BATCH_SIZE = 250 @@ -627,11 +647,31 @@ function bindKnowledgeDocumentWriteSecretProvenance(options: { /** Per-call cap for `tasks.batchTrigger` on Trigger.dev SDK 4.3.1+. */ const TRIGGER_BATCH_SIZE = 1000 +/** + * Immediate outcome of handing document work to its execution backend. + * + * `accepted` means Trigger.dev accepted the child run, the direct fallback + * finished the job, or a concurrent caller already installed a live queue + * generation for the document. It deliberately does not claim that an + * asynchronous child succeeded: that eventual outcome belongs to the document + * row and child task run, neither of which the dispatching connector waits for. + */ +export interface DocumentProcessingDispatchResult { + requested: number + accepted: number + failed: number + /** Deduplicated input IDs whose work this call neither accepted nor found live. */ + failedDocumentIds: string[] +} + function buildJobPayload( doc: DocumentData, knowledgeBaseId: string, processingOptions: ProcessingOptions, requestId: string, + processingQueueToken: string, + processingQueuedAt: Date, + chargedAtDispatch: boolean, billingContext: DocumentProcessingBillingContext ): DocumentProcessingPayload { return createDocumentProcessingPayload( @@ -646,6 +686,9 @@ function buildJobPayload( }, processingOptions, requestId, + processingQueueToken, + chargedAtDispatch, + processingQueuedAt: processingQueuedAt.toISOString(), }, billingContext ) @@ -705,39 +748,201 @@ async function resolveDocumentProcessingBillingContext( * queue has not started, and a leftover value from a prior run would otherwise * be reported as this attempt's start time. * - * Guarded on `pending` so it can never disturb a document a worker has already - * claimed — `processDocumentAsync` uses `processingStartedAt` as a - * compare-and-set token, and overwriting it under a live run would strand that - * run's completion writes. If the worker wins the race, this update matches no - * rows and the worker's own timestamps stand, which is the correct outcome: - * queue wait no longer matters once processing has begun. + * Guarded on `pending` and an empty queue timestamp, so concurrent dispatch + * callers cannot both charge and enqueue the same document. A retained token + * with no timestamp identifies a withdrawn generation and is atomically + * replaced here; the old generation can no longer finalize the row afterward. + * Returning the rows this write claimed lets the caller dispatch only its own + * generation. A worker that already claimed the row or another caller that + * already queued it wins cleanly. */ -async function markDocumentsQueued(documentIds: string[], queuedAt: Date): Promise { - await db - .update(document) - .set({ - processingQueuedAt: queuedAt, - processingStartedAt: null, - // Spent here because this is the one write every dispatch passes through, - // and it is already guarded — so the budget cannot be charged twice for a - // single dispatch, nor skipped by a caller that dispatches another way. - // Refunded by `clearDocumentsQueued` when the dispatch provably never - // happened, so only attempts a worker could have seen are ever spent. - processingAttempts: sql`${document.processingAttempts} + 1`, - }) - .where(and(inArray(document.id, documentIds), eq(document.processingStatus, 'pending'))) +interface QueuedDocumentGeneration { + readonly documentId: string + readonly processingQueuedAt: Date + readonly chargedAtDispatch: boolean +} + +interface MarkDocumentsQueuedResult { + readonly generations: QueuedDocumentGeneration[] + readonly acceptedWithoutDispatchIds: string[] + readonly unresolvedIds: string[] +} + +function acceptedDocumentStateCondition(observedAt: Date): SQL | undefined { + const queuedCutoff = new Date(observedAt.getTime() - QUEUED_DISPATCH_GRACE_MS) + const processingCutoff = new Date(observedAt.getTime() - DOCUMENT_PROCESSING_STALE_THRESHOLD_MS) + return or( + eq(document.processingStatus, 'completed'), + and( + eq(document.processingStatus, 'pending'), + isNotNull(document.processingQueuedAt), + gte(document.processingQueuedAt, queuedCutoff) + ), + and( + eq(document.processingStatus, 'processing'), + isNotNull(document.processingStartedAt), + gte(document.processingStartedAt, processingCutoff) + ) + ) +} + +async function isDocumentAcceptedWithoutDispatch( + documentId: string, + knowledgeBaseId: string, + observedAt: Date +): Promise { + const accepted = await db + .select({ id: document.id }) + .from(document) + .where( + and( + eq(document.id, documentId), + eq(document.knowledgeBaseId, knowledgeBaseId), + acceptedDocumentStateCondition(observedAt), + eq(document.userExcluded, false), + isNull(document.archivedAt), + isNull(document.deletedAt) + ) + ) + .limit(1) + return accepted.length > 0 +} + +async function markDocumentsQueued( + documentIds: string[], + knowledgeBaseId: string, + queueToken: string, + queuedAt: Date +): Promise { + const legacyAdoptionCutoff = new Date(queuedAt.getTime() - QUEUED_DISPATCH_GRACE_MS) + return db.transaction(async (tx) => { + const claimed = await tx + .update(document) + .set({ + processingQueuedAt: queuedAt, + processingQueueToken: queueToken, + processingStartedAt: null, + processingDeferredUntil: null, + /** + * Spent here because this is the one write every dispatch passes through, + * and it is already guarded — so the budget cannot be charged twice for a + * single dispatch, nor skipped by a caller that dispatches another way. + * Refunded by `clearDocumentsQueued` when the dispatch provably never + * happened, so only attempts a worker could have seen are ever spent. + */ + processingAttempts: sql`${document.processingAttempts} + 1`, + }) + .where( + and( + inArray(document.id, documentIds), + eq(document.knowledgeBaseId, knowledgeBaseId), + eq(document.processingStatus, 'pending'), + eq(document.userExcluded, false), + isNull(document.processingQueuedAt), + isNull(document.archivedAt), + isNull(document.deletedAt) + ) + ) + .returning({ id: document.id }) + + const claimedIds = new Set(claimed.map((row) => row.id)) + const unclaimedIds = documentIds.filter((documentId) => !claimedIds.has(documentId)) + const resumed = + unclaimedIds.length === 0 + ? [] + : await tx + .update(document) + .set({ processingQueueToken: queueToken }) + .where( + and( + inArray(document.id, unclaimedIds), + eq(document.knowledgeBaseId, knowledgeBaseId), + or( + and( + or( + eq(document.processingStatus, 'pending'), + eq(document.processingStatus, 'failed') + ), + eq(document.processingQueueToken, queueToken) + ), + and( + eq(document.processingStatus, 'pending'), + isNull(document.processingQueueToken), + lt(document.processingQueuedAt, legacyAdoptionCutoff) + ) + ), + isNotNull(document.processingQueuedAt), + isNull(document.processingDeferredUntil), + eq(document.userExcluded, false), + isNull(document.archivedAt), + isNull(document.deletedAt) + ) + ) + .returning({ id: document.id, processingQueuedAt: document.processingQueuedAt }) + + const resumedIds = new Set(resumed.map((row) => row.id)) + const unresolvedIds = unclaimedIds.filter((documentId) => !resumedIds.has(documentId)) + const acceptedWithoutDispatch = + unresolvedIds.length === 0 + ? [] + : await tx + .select({ id: document.id }) + .from(document) + .where( + and( + inArray(document.id, unresolvedIds), + eq(document.knowledgeBaseId, knowledgeBaseId), + acceptedDocumentStateCondition(queuedAt), + eq(document.userExcluded, false), + isNull(document.archivedAt), + isNull(document.deletedAt) + ) + ) + .for('update') + + const acceptedWithoutDispatchIds = acceptedWithoutDispatch.map((row) => row.id) + const acceptedWithoutDispatchIdSet = new Set(acceptedWithoutDispatchIds) + + return { + generations: [ + ...claimed.map((row) => ({ + documentId: row.id, + processingQueuedAt: queuedAt, + chargedAtDispatch: true, + })), + ...resumed.flatMap((row) => + row.processingQueuedAt + ? [ + { + documentId: row.id, + processingQueuedAt: row.processingQueuedAt, + chargedAtDispatch: false, + }, + ] + : [] + ), + ], + acceptedWithoutDispatchIds, + unresolvedIds: unresolvedIds.filter( + (documentId) => !acceptedWithoutDispatchIdSet.has(documentId) + ), + } + }) } /** - * Withdraws a queue stamp whose dispatch provably never happened. + * Withdraws a live queue timestamp whose dispatch provably never happened. * * {@link markDocumentsQueued} runs before dispatch on purpose — `batchTrigger` * chunks, so a batch can half-succeed, and stamping afterwards would leave the * runs that did start with no stamp and no grace. The cost of that ordering is * that a batch where *every* dispatch failed still carries a fresh stamp, and * recovery sweeps would honour a grace period the documents did not earn. Total - * failure is the one case where nothing was dispatched, so the stamp can be - * taken back and the next sweep is free to reclaim them immediately. + * failure is the one case where nothing was dispatched, so the timestamp can + * be taken back and the next sweep is free to reclaim them immediately. The + * generation token stays until an exact-token failure recorder finalizes it or + * a newer dispatcher adopts the timestamp-less row. That ownership marker + * prevents an older recorder from failing a newer blank pending generation. * * The attempt {@link markDocumentsQueued} charged is refunded in the same * statement. The budget exists to stop re-billing a document that keeps failing @@ -757,7 +962,11 @@ async function markDocumentsQueued(documentIds: string[], queuedAt: Date): Promi * call wrote, so a concurrent dispatch that has already re-stamped a document * is left alone. */ -async function clearDocumentsQueued(documentIds: string[], queuedAt: Date): Promise { +async function clearDocumentsQueued( + documentIds: string[], + queueToken: string, + queuedAt: Date +): Promise { await db .update(document) .set({ @@ -768,15 +977,34 @@ async function clearDocumentsQueued(documentIds: string[], queuedAt: Date): Prom and( inArray(document.id, documentIds), eq(document.processingStatus, 'pending'), + eq(document.processingQueueToken, queueToken), eq(document.processingQueuedAt, queuedAt) ) ) } +async function bestEffortWithdrawDocumentsQueued( + documentIds: string[], + queueToken: string, + queuedAt: Date, + reason: string +): Promise { + if (documentIds.length === 0) return + try { + await clearDocumentsQueued(documentIds, queueToken, queuedAt) + } catch (error) { + logger.warn(`[${queueToken}] Failed to withdraw queue ownership after ${reason}`, { + error: getErrorMessage(error), + }) + } +} + /** * Dispatches document processing jobs via Trigger.dev's `batchTrigger` when * available, or in-process otherwise. Throws only when every dispatch fails; - * partial failures are logged and recovered by the next sync's stuck-doc pass. + * partial failures are returned and recovered by the next sync's stuck-doc + * pass. A successful Trigger.dev hand-off is only an accepted child run, not a + * claim about its eventual processing outcome. */ export async function processDocumentsWithQueue( createdDocuments: DocumentData[], @@ -784,20 +1012,79 @@ export async function processDocumentsWithQueue( processingOptions: ProcessingOptions, requestId: string, billingAttribution: BillingAttributionSnapshot | undefined -): Promise { - if (createdDocuments.length === 0) return +): Promise { + const seenDocumentIds = new Set() + const uniqueDocuments = createdDocuments.filter((createdDocument) => { + if (seenDocumentIds.has(createdDocument.documentId)) return false + seenDocumentIds.add(createdDocument.documentId) + return true + }) + if (uniqueDocuments.length === 0) { + return { requested: 0, accepted: 0, failed: 0, failedDocumentIds: [] } + } - const billingContext = await resolveDocumentProcessingBillingContext( - knowledgeBaseId, - billingAttribution + const requested = uniqueDocuments.length + const queuedAt = new Date() + const documentIds = uniqueDocuments.map((doc) => doc.documentId) + const { + generations: queuedGenerations, + acceptedWithoutDispatchIds, + unresolvedIds, + } = await markDocumentsQueued(documentIds, knowledgeBaseId, requestId, queuedAt) + const generationByDocumentId = new Map( + queuedGenerations.map((generation) => [generation.documentId, generation]) ) - const jobPayloads = createdDocuments.map((doc) => - buildJobPayload(doc, knowledgeBaseId, processingOptions, requestId, billingContext) + const queuedDocuments = uniqueDocuments.filter((doc) => + generationByDocumentId.has(doc.documentId) ) + const acceptedWithoutDispatch = acceptedWithoutDispatchIds.length + const unresolved = unresolvedIds.length + const newlyClaimedIds = queuedGenerations + .filter((generation) => generation.chargedAtDispatch) + .map((generation) => generation.documentId) - const documentIds = createdDocuments.map((doc) => doc.documentId) - const queuedAt = new Date() - await markDocumentsQueued(documentIds, queuedAt) + let billingContext: DocumentProcessingBillingContext + try { + billingContext = await resolveDocumentProcessingBillingContext( + knowledgeBaseId, + billingAttribution + ) + } catch (error) { + await bestEffortWithdrawDocumentsQueued( + newlyClaimedIds, + requestId, + queuedAt, + 'billing-context resolution failed' + ) + throw error + } + + if (queuedDocuments.length === 0) { + logger.info(`[${requestId}] No documents were eligible for a new processing dispatch`, { + acceptedWithoutDispatch, + unresolved, + }) + return { + requested, + accepted: acceptedWithoutDispatch, + failed: unresolved, + failedDocumentIds: unresolvedIds, + } + } + + const jobPayloads = queuedDocuments.map((doc) => { + const generation = generationByDocumentId.get(doc.documentId)! + return buildJobPayload( + doc, + knowledgeBaseId, + processingOptions, + requestId, + requestId, + generation.processingQueuedAt, + generation.chargedAtDispatch, + billingContext + ) + }) const useTrigger = isTriggerAvailable() logger.info( @@ -805,37 +1092,66 @@ export async function processDocumentsWithQueue( { backend: useTrigger ? 'trigger-dev' : 'direct' } ) - const dispatched = useTrigger - ? await dispatchViaBatchTrigger(jobPayloads, requestId) - : await dispatchInProcess(jobPayloads, requestId) + let dispatchedIds: Set + try { + dispatchedIds = useTrigger + ? await dispatchViaBatchTrigger(jobPayloads, requestId) + : await dispatchInProcess(jobPayloads, requestId) + } catch (error) { + await bestEffortWithdrawDocumentsQueued( + newlyClaimedIds, + requestId, + queuedAt, + 'dispatch setup failed' + ) + throw error + } logger.info( - `[${requestId}] Document dispatch complete: ${dispatched}/${jobPayloads.length} succeeded` + `[${requestId}] Document dispatch complete: ${dispatchedIds.size}/${jobPayloads.length} accepted` ) - if (dispatched === 0) { - /** - * Best-effort, unlike the stamp itself: failing to write the stamp means the - * grace cannot be promised and dispatching anyway is the unsafe direction, so - * that write throws. Failing to withdraw one only delays recovery by a grace - * period, so it must not mask the dispatch failure that is the real error. - */ - try { - await clearDocumentsQueued(documentIds, queuedAt) - } catch (error) { - logger.warn(`[${requestId}] Failed to withdraw the queue stamp after a failed dispatch`, { - error: getErrorMessage(error), - }) + /** + * Refund every newly owned generation that provably failed before claiming + * processing, including one failed chunk in an otherwise successful batch. + */ + const failedNewlyClaimedIds = newlyClaimedIds.filter( + (documentId) => !dispatchedIds.has(documentId) + ) + await bestEffortWithdrawDocumentsQueued( + failedNewlyClaimedIds, + requestId, + queuedAt, + 'a newly claimed dispatch failed' + ) + + if (dispatchedIds.size === 0) { + if (acceptedWithoutDispatch === 0) { + throw new Error(`All ${jobPayloads.length} document processing dispatches failed`) } - throw new Error(`All ${jobPayloads.length} document processing dispatches failed`) + } + + const unresolvedIdSet = new Set(unresolvedIds) + const failedDocumentIds = uniqueDocuments.flatMap((doc) => + unresolvedIdSet.has(doc.documentId) || + (generationByDocumentId.has(doc.documentId) && !dispatchedIds.has(doc.documentId)) + ? [doc.documentId] + : [] + ) + + return { + requested, + accepted: acceptedWithoutDispatch + dispatchedIds.size, + failed: failedDocumentIds.length, + failedDocumentIds, } } async function dispatchViaBatchTrigger( jobPayloads: DocumentProcessingPayload[], requestId: string -): Promise { - let dispatched = 0 +): Promise> { + const dispatchedIds = new Set() const batchIds: string[] = [] const undispatched: DocumentProcessingPayload[] = [] const region = await resolveTriggerRegion() @@ -859,7 +1175,7 @@ async function dispatchViaBatchTrigger( })) ) batchIds.push(result.batchId) - dispatched += chunk.length + for (const payload of chunk) dispatchedIds.add(payload.documentId) } catch (error) { logger.error(`[${requestId}] Failed to batchTrigger ${chunk.length} document jobs`, { error: getErrorMessage(error), @@ -880,23 +1196,40 @@ async function dispatchViaBatchTrigger( logger.warn( `[${requestId}] Processing ${undispatched.length} documents in-process after failed enqueue` ) - dispatched += await dispatchInProcess(undispatched, requestId) + const directlyDispatchedIds = await dispatchInProcess(undispatched, requestId) + for (const documentId of directlyDispatchedIds) dispatchedIds.add(documentId) } - return dispatched + return dispatchedIds } /** Each in-process job runs chunking + embedding + many DB inserts. */ const IN_PROCESS_DISPATCH_CONCURRENCY = 5 +export interface DocumentProcessingAttemptContext { + /** True only when this invocation follows a successful queue-budget charge. */ + readonly chargedAtDispatch: boolean + /** Opaque generation token; absent only for payloads created before token rollout. */ + readonly processingQueueToken?: string + /** Queue generation this invocation is allowed to claim. */ + readonly processingQueuedAt?: Date + /** Durably schedules the next quota attempt and returns its execution time. */ + readonly scheduleQuotaContinuation?: () => Promise + /** The durable quota retry horizon was exhausted for this indexing pass. */ + readonly quotaContinuationExhausted?: boolean + /** Signals that this invocation owns the persisted processing generation. */ + readonly onClaimed?: () => void +} + async function dispatchInProcess( jobPayloads: DocumentProcessingPayload[], requestId: string -): Promise { +): Promise> { const results = await mapWithConcurrency( jobPayloads, IN_PROCESS_DISPATCH_CONCURRENCY, async (p) => { + let processingClaimed = false try { await processDocumentAsync( p.knowledgeBaseId, @@ -904,16 +1237,68 @@ async function dispatchInProcess( p.docData, p.processingOptions, p, - p.requestId + p.requestId, + { + chargedAtDispatch: p.chargedAtDispatch ?? true, + processingQueueToken: p.processingQueueToken, + ...(p.processingQueuedAt ? { processingQueuedAt: new Date(p.processingQueuedAt) } : {}), + scheduleQuotaContinuation: () => scheduleDocumentProcessingQuotaContinuation(p), + onClaimed: () => { + processingClaimed = true + }, + } ) - return true + if (processingClaimed) return true + + const acceptedByLiveGeneration = await isDocumentAcceptedWithoutDispatch( + p.documentId, + p.knowledgeBaseId, + new Date() + ) + return acceptedByLiveGeneration } catch (error) { - logger.error(`[${requestId}] Document dispatch failed`, { error: getErrorMessage(error) }) - return false + if (isPermanentDocumentProcessingError(error)) { + logger.warn(`[${requestId}] Document processing reached an expected terminal state`, { + code: error.code, + }) + return true + } + if (isEmbeddingQuotaExhaustion(error)) { + logger.warn(`[${requestId}] Embedding quota is exhausted; continuation scheduled`, { + documentId: p.documentId, + quotaRetryCount: p.quotaRetryCount ?? 0, + }) + return true + } + const message = processingClaimed + ? 'In-process document processing failed' + : 'In-process document dispatch failed before claiming the document' + logger.error(`[${requestId}] ${message}`, { + documentId: p.documentId, + error: getErrorMessage(error), + }) + return processingClaimed } } ) - return results.filter(Boolean).length + return new Set( + results.flatMap((succeeded, index) => (succeeded ? [jobPayloads[index].documentId] : [])) + ) +} + +function queueGenerationConditions( + attemptContext: DocumentProcessingAttemptContext | undefined +): SQL[] { + if (!attemptContext) return [] + if (attemptContext.processingQueueToken) { + return [eq(document.processingQueueToken, attemptContext.processingQueueToken)] + } + return attemptContext.processingQueuedAt + ? [ + isNull(document.processingQueueToken), + eq(document.processingQueuedAt, attemptContext.processingQueuedAt), + ] + : [isNull(document.processingQueueToken)] } /** @@ -926,6 +1311,9 @@ async function dispatchInProcess( * user-triggered reprocess) mints a fresh one. It is what makes the embedding * charge bill once per pass — see the `sourceReference` note at the `recordUsage` * call below. + * @param attemptContext - Identifies whether queue admission charged this + * invocation against the document's retry budget. Direct callers omit it and + * therefore cannot refund an attempt they never charged. */ export async function processDocumentAsync( knowledgeBaseId: string, @@ -938,10 +1326,12 @@ export async function processDocumentAsync( }, processingOptions: ProcessingOptions = {}, providedBillingContext?: BillingAttributionSnapshot | DocumentProcessingBillingContext, - indexingPassId?: string + indexingPassId?: string, + attemptContext?: DocumentProcessingAttemptContext ): Promise { const startTime = Date.now() const processingStartedAt = new Date() + let processingFilename = docData.filename try { logger.info(`[${documentId}] Starting document processing`, { knowledgeBaseId, @@ -990,6 +1380,7 @@ export async function processDocumentAsync( and( eq(document.id, documentId), eq(knowledgeBase.id, knowledgeBaseId), + eq(document.userExcluded, false), isNull(document.archivedAt), isNull(document.deletedAt), isNull(knowledgeBase.deletedAt) @@ -1006,6 +1397,7 @@ export async function processDocumentAsync( .set({ processingStatus: 'failed', processingError: 'Document or knowledge base no longer exists', + processingDeferredUntil: null, processingCompletedAt: new Date(), }) // Never overwrite a finished pass, and never resurrect state on a row @@ -1014,6 +1406,8 @@ export async function processDocumentAsync( and( eq(document.id, documentId), ne(document.processingStatus, 'completed'), + ...queueGenerationConditions(attemptContext), + eq(document.userExcluded, false), isNull(document.archivedAt), isNull(document.deletedAt) ) @@ -1022,6 +1416,7 @@ export async function processDocumentAsync( } const ctx = contextRows[0] + processingFilename = ctx.filename const persistedDocData = { filename: ctx.filename, fileUrl: ctx.fileUrl, @@ -1030,20 +1425,24 @@ export async function processDocumentAsync( } /** - * Claiming is guarded on the document not already being `completed`. + * Claiming is guarded by both completion status and queue generation. * * Without a status predicate this write was reachable for a finished * document — a late or duplicate dispatch would flip `completed` back to * `processing`, discard the pass that had already indexed and billed, and - * index it a second time. `pending`, `failed` and `processing` stay - * claimable so a Trigger.dev retry of the same run still proceeds; the - * commit CAS downstream is what keeps two live workers from both finishing. + * index it a second time. `pending`, `failed`, and `processing` remain + * claimable so a Trigger retry can recover if an earlier attempt threw + * before persisting its failure. Queued workers also match the exact stamp + * carried in their payload. A retry or recovery sweep re-stamps the row, so + * an older delayed quota continuation becomes a harmless no-op instead of + * stealing the newer pass. */ const claimed = await db .update(document) .set({ processingStatus: 'processing', processingStartedAt, + processingDeferredUntil: null, processingCompletedAt: null, processingError: null, }) @@ -1051,6 +1450,8 @@ export async function processDocumentAsync( and( eq(document.id, documentId), ne(document.processingStatus, 'completed'), + ...queueGenerationConditions(attemptContext), + eq(document.userExcluded, false), isNull(document.archivedAt), isNull(document.deletedAt) ) @@ -1059,11 +1460,13 @@ export async function processDocumentAsync( if (claimed.length === 0) { logger.info( - `[${documentId}] Skipping document processing: already completed, archived, or deleted` + `[${documentId}] Skipping document processing: superseded, already active, completed, archived, or deleted` ) return } + attemptContext?.onClaimed?.() + logger.info(`[${documentId}] Status updated to 'processing', starting document processor`) const rawConfig = ctx.chunkingConfig as { @@ -1123,22 +1526,9 @@ export async function processDocumentAsync( : await checkActorUsageLimits(documentActorUserId) if (usageGate.isExceeded) { logger.warn(`[${documentId}] Usage limit reached — skipping document indexing`) - await db - .update(document) - .set({ - processingStatus: 'failed', - processingError: - usageGate.message ?? 'Usage limit exceeded. Please upgrade your plan to continue.', - processingCompletedAt: new Date(), - }) - .where( - and( - eq(document.id, documentId), - eq(document.processingStatus, 'processing'), - eq(document.processingStartedAt, processingStartedAt) - ) - ) - return + throw new UsageLimitDocumentProcessingError( + usageGate.message ?? 'Usage limit exceeded. Please upgrade your plan to continue.' + ) } let billableEmbeddingTokens = 0 let embeddingModelName = kbEmbeddingModel @@ -1179,12 +1569,7 @@ export async function processDocumentAsync( rawConfig?.strategyOptions ) - if (processed.chunks.length > LARGE_DOC_CONFIG.MAX_CHUNKS_PER_DOCUMENT) { - throw new Error( - `Document has ${processed.chunks.length.toLocaleString()} chunks, exceeding maximum of ${LARGE_DOC_CONFIG.MAX_CHUNKS_PER_DOCUMENT.toLocaleString()}. ` + - `This document is unusually large and may need to be split into multiple files or preprocessed to reduce content.` - ) - } + assertDocumentChunkCountWithinLimit(processed.chunks.length) const now = new Date() @@ -1193,6 +1578,19 @@ export async function processDocumentAsync( ) const chunkTexts = processed.chunks.map((chunk) => chunk.text) + const embeddingModelInfo = getEmbeddingModelInfo(kbEmbeddingModel) + for (let chunkIndex = 0; chunkIndex < chunkTexts.length; chunkIndex++) { + const tokenCount = estimateTokenCount( + chunkTexts[chunkIndex], + embeddingModelInfo.tokenizerProvider + ).count + if (tokenCount > embeddingModelInfo.maxInputTokens) { + throw new PermanentDocumentProcessingError( + 'document_complexity_limit', + `Chunk ${chunkIndex + 1} contains ${tokenCount.toLocaleString()} estimated tokens, exceeding the ${embeddingModelInfo.maxInputTokens.toLocaleString()}-token limit for ${kbEmbeddingModel}. Reduce the knowledge-base chunk size and retry.` + ) + } + } const embeddings: number[][] = [] if (chunkTexts.length > 0) { @@ -1228,7 +1626,7 @@ export async function processDocumentAsync( logger.info(`[${documentId}] Embeddings generated, creating embedding records with tags`) - const tokenizerProvider = getEmbeddingModelInfo(kbEmbeddingModel).tokenizerProvider + const tokenizerProvider = embeddingModelInfo.tokenizerProvider const chunkProvenances = processed.chunks.map((chunk) => documentSecretContext.tracked @@ -1282,6 +1680,8 @@ export async function processDocumentAsync( eq(document.id, documentId), eq(document.processingStatus, 'processing'), eq(document.processingStartedAt, processingStartedAt), + ...queueGenerationConditions(attemptContext), + eq(document.userExcluded, false), isNull(document.archivedAt), isNull(document.deletedAt), isNull(knowledgeBase.deletedAt) @@ -1339,12 +1739,19 @@ export async function processDocumentAsync( // A completed pass clears the budget: the next failure starts // from a full allowance rather than inheriting a stale count. processingAttempts: 0, + processingQueueToken: null, + processingQueuedAt: null, + processingDeferredUntil: null, }) .where( and( eq(document.id, documentId), eq(document.processingStatus, 'processing'), - eq(document.processingStartedAt, processingStartedAt) + eq(document.processingStartedAt, processingStartedAt), + ...queueGenerationConditions(attemptContext), + eq(document.userExcluded, false), + isNull(document.archivedAt), + isNull(document.deletedAt) ) ) return true @@ -1439,30 +1846,76 @@ export async function processDocumentAsync( } } catch (error) { const processingTime = Date.now() - startTime - const errorMessage = getErrorMessage(error, 'Unknown error') - logger.error(`[${documentId}] Failed to process document after ${processingTime}ms:`, { - errorType: toError(error).name, + const embeddingQuotaExhausted = isEmbeddingQuotaExhaustion(error) + const usageLimitExceeded = isUsageLimitDocumentProcessingError(error) + const permanentError = toPermanentDocumentProcessingError(error, processingFilename) + let recordedError = permanentError ?? error + let quotaDeferredUntil: Date | null = null + let quotaContinuationAttempted = false + if (embeddingQuotaExhausted && attemptContext?.scheduleQuotaContinuation) { + quotaContinuationAttempted = true + try { + quotaDeferredUntil = await attemptContext.scheduleQuotaContinuation() + } catch (continuationError) { + recordedError = continuationError + } + } + const quotaContinuationFailed = quotaContinuationAttempted && !quotaDeferredUntil + const errorMessage = embeddingQuotaExhausted + ? quotaContinuationFailed + ? getErrorMessage(recordedError, 'Embedding quota continuation dispatch failed') + : EMBEDDING_QUOTA_EXHAUSTED_MESSAGE + : getErrorMessage(recordedError, 'Unknown error') + const logContext = { + errorType: toError(recordedError).name, knowledgeBaseId, mimeType: docData.mimeType, fileSize: docData.fileSize, - }) + } + const logMessage = quotaDeferredUntil + ? `[${documentId}] Deferred document processing after ${processingTime}ms:` + : `[${documentId}] Failed to process document after ${processingTime}ms:` + if ( + (embeddingQuotaExhausted && !quotaContinuationFailed) || + usageLimitExceeded || + permanentError + ) { + logger.warn(logMessage, logContext) + } else { + logger.error(logMessage, logContext) + } await db .update(document) .set({ - processingStatus: 'failed', - processingError: errorMessage, - processingCompletedAt: new Date(), + processingStatus: quotaDeferredUntil ? 'pending' : 'failed', + processingError: quotaDeferredUntil ? null : errorMessage, + processingStartedAt: quotaDeferredUntil ? null : processingStartedAt, + ...(quotaDeferredUntil && attemptContext?.processingQueueToken + ? { processingQueuedAt: quotaDeferredUntil } + : {}), + processingDeferredUntil: quotaDeferredUntil, + processingCompletedAt: quotaDeferredUntil ? null : new Date(), + ...(permanentError || + (embeddingQuotaExhausted && attemptContext?.quotaContinuationExhausted) + ? { processingAttempts: MAX_PROCESSING_ATTEMPTS } + : (embeddingQuotaExhausted || usageLimitExceeded) && attemptContext?.chargedAtDispatch + ? { processingAttempts: sql`GREATEST(${document.processingAttempts} - 1, 0)` } + : {}), }) .where( and( eq(document.id, documentId), eq(document.processingStatus, 'processing'), - eq(document.processingStartedAt, processingStartedAt) + eq(document.processingStartedAt, processingStartedAt), + ...queueGenerationConditions(attemptContext), + eq(document.userExcluded, false), + isNull(document.archivedAt), + isNull(document.deletedAt) ) ) - throw error + throw recordedError } } @@ -2614,11 +3067,16 @@ export async function bulkDocumentOperationByFilter( } export async function markDocumentAsFailedTimeout( + knowledgeBaseId: string, documentId: string, processingStartedAt: Date, requestId: string ): Promise<{ success: boolean; processingDuration: number }> { - const result = await failStaleDocumentProcessingClaim({ documentId, processingStartedAt }) + const result = await failStaleDocumentProcessingClaim({ + knowledgeBaseId, + documentId, + processingStartedAt, + }) if (result.success) { logger.info( @@ -2672,9 +3130,14 @@ export async function retryDocumentProcessing( .update(document) .set({ processingStatus: 'pending', - // `processingQueuedAt` is stamped by `markDocumentsQueued` on the - // dispatch below, for this and every other caller. + /** + * Invalidates the prior dispatch generation in the same write that + * reopens the row. The dispatch below installs its fresh generation. + */ + processingQueuedAt: null, + processingQueueToken: null, processingStartedAt: null, + processingDeferredUntil: null, processingCompletedAt: null, processingError: null, chunkCount: 0, @@ -2688,7 +3151,11 @@ export async function retryDocumentProcessing( inArray(document.processingStatus, ['completed', 'failed']), and( eq(document.processingStatus, 'pending'), - sql`COALESCE(${document.processingQueuedAt}, ${document.uploadedAt}) < ${sql.param(queuedGraceCutoff, document.processingQueuedAt)}` + sql`COALESCE(${document.processingQueuedAt}, ${document.uploadedAt}) < ${sql.param(queuedGraceCutoff, document.processingQueuedAt)}`, + or( + isNull(document.processingDeferredUntil), + lt(document.processingDeferredUntil, queuedGraceCutoff) + ) ) ), isNull(document.archivedAt), @@ -2723,7 +3190,7 @@ export async function retryDocumentProcessing( * retryable and visible in the document list with its reason. */ try { - await processDocumentsWithQueue( + const dispatch = await processDocumentsWithQueue( [ { documentId, @@ -2738,6 +3205,9 @@ export async function retryDocumentProcessing( requestId, billingAttribution ) + if (dispatch.failed > 0 || dispatch.accepted !== 1) { + throw new Error(`Document processing dispatch was not accepted for ${documentId}`) + } } catch (error) { const failureMessage = getErrorMessage(error, 'Document processing dispatch failed') await recordUndispatchedDocumentFailure({ @@ -3184,6 +3654,19 @@ async function deleteDocumentsByLifecyclePolicy( return excludedCount + hardDeletedCount } +export class ConnectorSyncDeletionGuardError extends Error { + constructor() { + super('Connector sync no longer owns the destructive document operation') + this.name = 'ConnectorSyncDeletionGuardError' + } +} + +export interface ConnectorSyncDeletionGuard { + connectorId: string + knowledgeBaseId: string + syncLockToken: string +} + export async function hardDeleteDocuments( documentIds: string[], requestId: string, @@ -3197,7 +3680,8 @@ export async function hardDeleteDocuments( * despite no longer belonging to the connector the caller reasoned about. */ expectedConnectorId?: string, - expectedKnowledgeBaseId?: string + expectedKnowledgeBaseId?: string, + connectorSyncGuard?: ConnectorSyncDeletionGuard ): Promise { const ids = [...new Set(documentIds)] if (ids.length === 0) { @@ -3210,7 +3694,8 @@ export async function hardDeleteDocuments( ids.slice(offset, offset + HARD_DELETE_DOCUMENT_BATCH_SIZE), requestId, expectedConnectorId, - expectedKnowledgeBaseId + expectedKnowledgeBaseId, + connectorSyncGuard ) } return deletedCount @@ -3224,9 +3709,14 @@ async function hardDeleteDocumentBatch( documentIds: string[], requestId: string, expectedConnectorId?: string, - expectedKnowledgeBaseId?: string + expectedKnowledgeBaseId?: string, + connectorSyncGuard?: ConnectorSyncDeletionGuard ): Promise { const ids = [...new Set(documentIds)] + const scopedConnectorId = connectorSyncGuard?.connectorId ?? expectedConnectorId + const scopedKnowledgeBaseId = connectorSyncGuard?.knowledgeBaseId ?? expectedKnowledgeBaseId + const requireEligibleDocument = Boolean(expectedKnowledgeBaseId || connectorSyncGuard) + const requireVisibleDocument = Boolean(expectedKnowledgeBaseId && !connectorSyncGuard) const documentsToDelete = await db .select({ id: document.id, @@ -3243,11 +3733,11 @@ async function hardDeleteDocumentBatch( .where( and( inArray(document.id, ids), - expectedConnectorId ? eq(document.connectorId, expectedConnectorId) : undefined, - expectedKnowledgeBaseId ? eq(document.knowledgeBaseId, expectedKnowledgeBaseId) : undefined, - expectedKnowledgeBaseId ? eq(document.userExcluded, false) : undefined, - expectedKnowledgeBaseId ? isNull(document.archivedAt) : undefined, - expectedKnowledgeBaseId ? isNull(document.deletedAt) : undefined + scopedConnectorId ? eq(document.connectorId, scopedConnectorId) : undefined, + scopedKnowledgeBaseId ? eq(document.knowledgeBaseId, scopedKnowledgeBaseId) : undefined, + requireEligibleDocument ? eq(document.userExcluded, false) : undefined, + requireEligibleDocument ? isNull(document.archivedAt) : undefined, + requireVisibleDocument ? isNull(document.deletedAt) : undefined ) ) @@ -3303,12 +3793,20 @@ async function hardDeleteDocumentBatch( userId: knowledgeBase.userId, }) .from(knowledgeBase) - .where(inArray(knowledgeBase.id, knowledgeBaseIds)) + .where( + and( + inArray(knowledgeBase.id, knowledgeBaseIds), + connectorSyncGuard ? isNull(knowledgeBase.deletedAt) : undefined + ) + ) .orderBy(asc(knowledgeBase.id)) .for('update') const lockedKnowledgeBaseById = new Map(lockedKnowledgeBases.map((kb) => [kb.id, kb])) for (const doc of documentsToDelete) { const lockedKb = lockedKnowledgeBaseById.get(doc.knowledgeBaseId) + if (!lockedKb && connectorSyncGuard) { + throw new ConnectorSyncDeletionGuardError() + } if ( !lockedKb || lockedKb.workspaceId !== doc.workspaceId || @@ -3320,6 +3818,27 @@ async function hardDeleteDocumentBatch( } } + if (connectorSyncGuard) { + const [heldSyncLock] = await tx + .select({ id: knowledgeConnector.id }) + .from(knowledgeConnector) + .where( + and( + eq(knowledgeConnector.id, connectorSyncGuard.connectorId), + eq(knowledgeConnector.knowledgeBaseId, connectorSyncGuard.knowledgeBaseId), + eq(knowledgeConnector.status, 'syncing'), + eq(knowledgeConnector.syncLockToken, connectorSyncGuard.syncLockToken), + isNull(knowledgeConnector.archivedAt), + isNull(knowledgeConnector.deletedAt) + ) + ) + .for('update') + + if (!heldSyncLock) { + throw new ConnectorSyncDeletionGuardError() + } + } + /** * Re-verify `expectedConnectorId` here too, not only on the pre-transaction * SELECT above — the billing lookups and KB locking between that SELECT @@ -3331,7 +3850,7 @@ async function hardDeleteDocumentBatch( * ID set rather than the stale `existingIds`. */ const stillTargetedIds = - expectedConnectorId || expectedKnowledgeBaseId + scopedConnectorId || scopedKnowledgeBaseId ? ( await tx .select({ id: document.id }) @@ -3339,15 +3858,17 @@ async function hardDeleteDocumentBatch( .where( and( inArray(document.id, existingIds), - expectedConnectorId ? eq(document.connectorId, expectedConnectorId) : undefined, - expectedKnowledgeBaseId - ? eq(document.knowledgeBaseId, expectedKnowledgeBaseId) + scopedConnectorId ? eq(document.connectorId, scopedConnectorId) : undefined, + scopedKnowledgeBaseId + ? eq(document.knowledgeBaseId, scopedKnowledgeBaseId) : undefined, - expectedKnowledgeBaseId ? eq(document.userExcluded, false) : undefined, - expectedKnowledgeBaseId ? isNull(document.archivedAt) : undefined, - expectedKnowledgeBaseId ? isNull(document.deletedAt) : undefined + requireEligibleDocument ? eq(document.userExcluded, false) : undefined, + requireEligibleDocument ? isNull(document.archivedAt) : undefined, + requireVisibleDocument ? isNull(document.deletedAt) : undefined ) ) + .orderBy(asc(document.id)) + .for('update') ).map((d) => d.id) : existingIds diff --git a/apps/sim/lib/knowledge/documents/storage-billing.test.ts b/apps/sim/lib/knowledge/documents/storage-billing.test.ts index 00dcc7781b3..0fd36063203 100644 --- a/apps/sim/lib/knowledge/documents/storage-billing.test.ts +++ b/apps/sim/lib/knowledge/documents/storage-billing.test.ts @@ -1,7 +1,14 @@ /** * @vitest-environment node */ -import { dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing' +import { + dbChainMock, + dbChainMockFns, + flattenMockConditions, + queueTableRows, + resetDbChainMock, + schemaMock, +} from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' const { @@ -46,6 +53,7 @@ vi.mock('@/lib/knowledge/documents/processing-outbox-event', () => ({ })) import { + ConnectorSyncDeletionGuardError, createDocumentRecords, createSingleDocument, hardDeleteDocuments, @@ -378,6 +386,158 @@ describe('knowledge document storage attribution', () => { expect(mockDecrementStorageUsageForBillingContextInTx).not.toHaveBeenCalled() }) + it('refuses connector reconciliation deletion after the sync lock is lost', async () => { + dbChainMockFns.where.mockResolvedValueOnce([ + { + id: 'connector-doc', + knowledgeBaseId: 'knowledge-base-1', + fileUrl: 'data:text/plain;base64,QQ==', + fileSize: 500, + uploadedBy: null, + connectorId: 'connector-1', + workspaceId: 'workspace-1', + kbUserId: 'knowledge-owner', + }, + ]) + dbChainMockFns.for + .mockResolvedValueOnce([ + { id: 'knowledge-base-1', workspaceId: 'workspace-1', userId: 'knowledge-owner' }, + ]) + .mockResolvedValueOnce([]) + + await expect( + hardDeleteDocuments(['connector-doc'], 'request-1', 'connector-1', undefined, { + connectorId: 'connector-1', + knowledgeBaseId: 'knowledge-base-1', + syncLockToken: 'sync-1', + }) + ).rejects.toBeInstanceOf(ConnectorSyncDeletionGuardError) + + expect(dbChainMockFns.delete).not.toHaveBeenCalled() + }) + + it('makes the connector sync guard self-contained without excluding tombstones', async () => { + const connectorDocument = { + id: 'connector-doc', + knowledgeBaseId: 'knowledge-base-1', + fileUrl: 'data:text/plain;base64,QQ==', + fileSize: 500, + uploadedBy: null, + connectorId: 'connector-1', + workspaceId: 'workspace-1', + kbUserId: 'knowledge-owner', + } + queueTableRows(schemaMock.document, [connectorDocument]) + queueTableRows(schemaMock.knowledgeBase, [ + { id: 'knowledge-base-1', workspaceId: 'workspace-1', userId: 'knowledge-owner' }, + ]) + queueTableRows(schemaMock.knowledgeConnector, [{ id: 'connector-1' }]) + queueTableRows(schemaMock.document, [{ id: 'connector-doc' }]) + dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'connector-doc' }]) + + await expect( + hardDeleteDocuments(['connector-doc'], 'request-1', undefined, undefined, { + connectorId: 'connector-1', + knowledgeBaseId: 'knowledge-base-1', + syncLockToken: 'sync-1', + }) + ).resolves.toBe(1) + + const conditions = dbChainMockFns.where.mock.calls.flatMap(([condition]) => + flattenMockConditions(condition) + ) + expect(conditions).toContainEqual({ + type: 'eq', + left: schemaMock.document.connectorId, + right: 'connector-1', + }) + expect(conditions).toContainEqual({ + type: 'eq', + left: schemaMock.document.knowledgeBaseId, + right: 'knowledge-base-1', + }) + expect(conditions).toContainEqual({ + type: 'eq', + left: schemaMock.document.userExcluded, + right: false, + }) + expect(conditions).toContainEqual({ + type: 'isNull', + column: schemaMock.document.archivedAt, + }) + expect(conditions).toContainEqual({ + type: 'eq', + left: schemaMock.knowledgeConnector.id, + right: 'connector-1', + }) + expect(conditions).toContainEqual({ + type: 'eq', + left: schemaMock.knowledgeConnector.knowledgeBaseId, + right: 'knowledge-base-1', + }) + expect(conditions).toContainEqual({ + type: 'eq', + left: schemaMock.knowledgeConnector.status, + right: 'syncing', + }) + expect(conditions).toContainEqual({ + type: 'eq', + left: schemaMock.knowledgeConnector.syncLockToken, + right: 'sync-1', + }) + expect(conditions).toContainEqual({ + type: 'isNull', + column: schemaMock.knowledgeConnector.archivedAt, + }) + expect(conditions).toContainEqual({ + type: 'isNull', + column: schemaMock.knowledgeConnector.deletedAt, + }) + expect(conditions).toContainEqual({ + type: 'isNull', + column: schemaMock.knowledgeBase.deletedAt, + }) + expect(conditions).not.toContainEqual({ + type: 'isNull', + column: schemaMock.document.deletedAt, + }) + expect(dbChainMockFns.for).toHaveBeenCalledTimes(3) + expect(dbChainMockFns.for).toHaveBeenLastCalledWith('update') + }) + + it('does not delete a document detached from the connector before the locked recheck', async () => { + const connectorDocument = { + id: 'connector-doc', + knowledgeBaseId: 'knowledge-base-1', + fileUrl: 'data:text/plain;base64,QQ==', + fileSize: 500, + uploadedBy: null, + connectorId: 'connector-1', + workspaceId: 'workspace-1', + kbUserId: 'knowledge-owner', + } + queueTableRows(schemaMock.document, [connectorDocument]) + queueTableRows(schemaMock.knowledgeBase, [ + { id: 'knowledge-base-1', workspaceId: 'workspace-1', userId: 'knowledge-owner' }, + ]) + queueTableRows(schemaMock.knowledgeConnector, [{ id: 'connector-1' }]) + queueTableRows(schemaMock.document, []) + dbChainMockFns.returning.mockResolvedValueOnce([]) + + await expect( + hardDeleteDocuments(['connector-doc'], 'request-1', undefined, undefined, { + connectorId: 'connector-1', + knowledgeBaseId: 'knowledge-base-1', + syncLockToken: 'sync-1', + }) + ).resolves.toBe(0) + + expect(mockApplyStorageUsageDeltasInTx).toHaveBeenCalledWith(expect.anything(), { + workspaceDeltas: [], + legacyDeltas: [], + }) + }) + it('splits hard deletion into bounded 250-document transactions', async () => { const documentIds = Array.from({ length: 251 }, (_, index) => `doc-${index}`) diff --git a/apps/sim/lib/knowledge/documents/types.ts b/apps/sim/lib/knowledge/documents/types.ts index cf4a23bad53..eca8991a467 100644 --- a/apps/sim/lib/knowledge/documents/types.ts +++ b/apps/sim/lib/knowledge/documents/types.ts @@ -9,9 +9,10 @@ * actually consumed: one attempt per *dispatch*, not per Trigger.dev retry, so * a short-interval connector can still burn several inside one transient * outage. A dispatch that provably reached nothing is refunded — see - * `clearDocumentsQueued` — which covers the total-failure shape, but a partial - * batch failure and an accepted dispatch whose run never starts both stay - * charged. Three left too little room for those; five still bounds the spend + * `clearDocumentsQueued` — which refunds each newly claimed dispatch that + * provably failed before processing began. An accepted dispatch whose remote + * run never starts still stays charged. Three left too little room for those; + * five still bounds the spend * well inside `RETRY_WINDOW_DAYS`. * * Reaching it is a dead letter, not a deletion: the document keeps its `failed` @@ -46,6 +47,36 @@ export const MAX_PROCESSING_ATTEMPTS = 5 */ export const QUEUED_DISPATCH_GRACE_MS = 240 * 60 * 1000 +/** Worst-case wall clock for one processing run across its retry budget. */ +export function worstCaseProcessingMinutes( + maxDurationSeconds: number, + maxAttempts: number +): number { + return (maxDurationSeconds * maxAttempts) / 60 +} + +/** Headroom over the worst case, so ordinary jitter never reclaims a live run. */ +const STALE_PROCESSING_HEADROOM = 1.5 + +/** Floor preserving the historical safe value at the default task settings. */ +const STALE_PROCESSING_FLOOR_MINUTES = 45 + +/** + * Minutes a `processing` document is given before it can be considered + * abandoned. Never below the worst case a legitimate retrying run can take. + */ +export function resolveStaleProcessingMinutes( + maxDurationSeconds: number, + maxAttempts: number +): number { + return Math.max( + STALE_PROCESSING_FLOOR_MINUTES, + Math.ceil( + worstCaseProcessingMinutes(maxDurationSeconds, maxAttempts) * STALE_PROCESSING_HEADROOM + ) + ) +} + /** * Every value `document.processing_status` may hold. * diff --git a/apps/sim/lib/knowledge/documents/unreadable-document.test.ts b/apps/sim/lib/knowledge/documents/unreadable-document.test.ts index 24e85ccb3c6..697bb413545 100644 --- a/apps/sim/lib/knowledge/documents/unreadable-document.test.ts +++ b/apps/sim/lib/knowledge/documents/unreadable-document.test.ts @@ -46,6 +46,21 @@ describe('unreadable document handling', () => { await expect(parse('Deck.pptx')).rejects.toThrow(/No text could be extracted/) }) + it('preserves degraded parser metadata for data-URI documents', async () => { + mockParseBuffer.mockResolvedValue({ + content: 'Unable to extract text from PowerPoint file.', + metadata: { extractionMethod: 'fallback', degraded: true }, + }) + + await expect( + processDocument( + 'data:application/vnd.ms-powerpoint;base64,Ynl0ZXM=', + 'Deck.ppt', + 'application/vnd.ms-powerpoint' + ) + ).rejects.toThrow(/Re-save it as PPTX/) + }) + it('names the modern container for a legacy format, which re-saving genuinely fixes', async () => { mockParseBuffer.mockResolvedValue({ content: 'Unable to extract text from DOC file.', diff --git a/apps/sim/lib/knowledge/documents/utils.test.ts b/apps/sim/lib/knowledge/documents/utils.test.ts index df58cebac26..ff5b6043f31 100644 --- a/apps/sim/lib/knowledge/documents/utils.test.ts +++ b/apps/sim/lib/knowledge/documents/utils.test.ts @@ -17,7 +17,9 @@ import { type HTTPError, hasRateLimitEvidence, isRetryableError, + readBoundedHttpErrorPayload, resolveRetryDelayMs, + retryWithExponentialBackoff, } from './utils' /** Case-insensitive header reader over a plain lowercase-keyed record. */ @@ -43,7 +45,26 @@ function fakeResponse( } } -const FAST_RETRY = { initialDelayMs: 1, maxDelayMs: 2, maxRetries: 3 } +const FAST_RETRY = { initialDelayMs: 1, maxDelayMs: 2, maxRetries: 3, retryBudgetMs: 1_000 } + +describe('readBoundedHttpErrorPayload', () => { + it('returns a typed failure and cancels a response body that exceeds 64KiB', async () => { + let cancelled = false + const body = new ReadableStream({ + pull(controller) { + controller.enqueue(new Uint8Array(64 * 1024 + 1)) + }, + cancel() { + cancelled = true + }, + }) + + const result = await readBoundedHttpErrorPayload(new Response(body)) + + expect(result).toEqual({ ok: false, reason: 'too_large' }) + expect(cancelled).toBe(true) + }) +}) describe('isRetryableError', () => { describe('retryable status codes', () => { @@ -67,6 +88,10 @@ describe('isRetryableError', () => { expect(isRetryableError(error)).toBe(true) }) + it.concurrent.each([520, 522])('returns true for transient Cloudflare status %i', (status) => { + expect(isRetryableError({ status })).toBe(true) + }) + it.concurrent('returns true for plain object with status 429', () => { expect(isRetryableError({ status: 429 })).toBe(true) }) @@ -323,8 +348,7 @@ describe('resolveRetryDelayMs', () => { /** * GitHub and X stamp their rate-limit headers on every response. Without the * evidence gate a transient 502 would be handed the rest of the hourly window - * as its wait, which the retry loop clamps to a flat maxDelayMs on every - * attempt — replacing the exponential ladder with 5x the wall-clock stall. + * as its wait instead of following the exponential backoff ladder. */ it.concurrent('ignores a reset header when the quota is NOT exhausted', () => { expect( @@ -378,7 +402,7 @@ describe('resolveRetryDelayMs', () => { }) /** The 30s default cap in `parseRetryAfter` must not truncate the value here. */ - it.concurrent('does not truncate a long Retry-After — the retry loop owns the cap', () => { + it.concurrent('does not truncate a long Retry-After — retry policy owns admission', () => { expect(resolveRetryDelayMs(headers({ 'retry-after': '900' }), NOW)).toBe(900_000) }) }) @@ -388,6 +412,7 @@ describe('fetchWithRetry rate-limit handling', () => { afterEach(() => { globalThis.fetch = originalFetch + vi.useRealTimers() }) /** Builds a Response-shaped object with real case-insensitive Headers. */ @@ -407,7 +432,7 @@ describe('fetchWithRetry rate-limit handling', () => { .mockResolvedValueOnce( response(403, { 'x-ratelimit-remaining': '0', - 'x-ratelimit-reset': String(Math.floor(Date.now() / 1000) + 1), + 'retry-after': '0.001', }) ) .mockResolvedValueOnce(response(200)) @@ -419,6 +444,91 @@ describe('fetchWithRetry rate-limit handling', () => { expect(fetchMock).toHaveBeenCalledTimes(2) }) + it.each([520, 522])('retries transient Cloudflare status %i and succeeds', async (status) => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce(response(status)) + .mockResolvedValueOnce(response(200)) + globalThis.fetch = fetchMock + + const result = await fetchWithRetry('https://api.fireflies.ai/graphql', {}, FAST_RETRY) + + expect(result.status).toBe(200) + expect(fetchMock).toHaveBeenCalledTimes(2) + }) + + it('bounds the provider body carried by a retry error', async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response('x'.repeat(70 * 1024), { + status: 522, + statusText: 'Connection timed out', + }) + ) + globalThis.fetch = fetchMock + + const error = await fetchWithRetry( + 'https://api.fireflies.ai/graphql', + {}, + { + ...FAST_RETRY, + maxRetries: 0, + } + ).then( + () => undefined, + (caught) => caught as Error + ) + + expect(error?.message).toContain('response body omitted') + expect(error?.message.length).toBeLessThan(500) + }) + + it('redacts credentials echoed by a retryable provider error', async () => { + const secret = 'sk-provider-secret-value-1234567890' + globalThis.fetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ authorization: `Bearer ${secret}` }), { + status: 522, + statusText: `Connection timed out: ${secret}`, + }) + ) + + const error = await fetchWithRetry( + 'https://api.fireflies.ai/graphql', + {}, + { ...FAST_RETRY, maxRetries: 0 } + ).then( + () => undefined, + (caught) => caught as Error + ) + + expect(error?.message).toContain('[response body omitted]') + expect(error?.message).not.toContain(secret) + }) + + it.each(['api_key', 'authorization', 'client_secret', 'credential', 'private_key'])( + 'redacts a long structured %s before truncating the diagnostic', + async (sensitiveKey) => { + const secretPrefix = 'sensitive-value-prefix' + globalThis.fetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ [sensitiveKey]: `${secretPrefix}${'x'.repeat(3000)}` }), { + status: 522, + statusText: 'Connection timed out', + }) + ) + + const error = await fetchWithRetry( + 'https://api.fireflies.ai/graphql', + {}, + { ...FAST_RETRY, maxRetries: 0 } + ).then( + () => undefined, + (caught) => caught as Error + ) + + expect(error?.message).toContain('[response body omitted]') + expect(error?.message).not.toContain(secretPrefix) + } + ) + it('does not retry an authorization 403 with quota remaining', async () => { const fetchMock = vi.fn().mockResolvedValue(response(403, { 'x-ratelimit-remaining': '4999' })) globalThis.fetch = fetchMock @@ -429,26 +539,57 @@ describe('fetchWithRetry rate-limit handling', () => { expect(fetchMock).toHaveBeenCalledTimes(1) }) - it('derives the wait from x-rate-limit-reset on a 429 with no Retry-After (X)', async () => { + it('does not retry GitHub before a reset beyond the operation budget', async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + response(403, { + 'x-ratelimit-remaining': '0', + 'x-ratelimit-reset': String(Math.floor(Date.now() / 1000) + 900), + }) + ) + .mockResolvedValueOnce(response(200)) + globalThis.fetch = fetchMock + + await expect(fetchWithRetry('https://api.github.com/repos', {}, FAST_RETRY)).rejects.toThrow( + 'HTTP 403' + ) + + expect(fetchMock).toHaveBeenCalledTimes(1) + }) + + it('waits until an admitted x-rate-limit-reset instant before retrying', async () => { + vi.useFakeTimers() + const now = 1_700_000_000_000 + vi.setSystemTime(now) const fetchMock = vi .fn() .mockResolvedValueOnce( response(429, { 'x-rate-limit-remaining': '0', - 'x-rate-limit-reset': String(Math.floor(Date.now() / 1000) + 900), + 'x-rate-limit-reset': String(now / 1000 + 1), }) ) .mockResolvedValueOnce(response(200)) globalThis.fetch = fetchMock - const started = Date.now() - // maxDelayMs clamps the 15-minute window down to 2ms for this test. - const result = await fetchWithRetry('https://api.twitter.com/2/users', {}, FAST_RETRY) + const result = fetchWithRetry( + 'https://api.twitter.com/2/users', + {}, + { + maxRetries: 1, + initialDelayMs: 1, + maxDelayMs: 10, + maxRetryAfterMs: 1_500, + retryBudgetMs: 1_500, + } + ) - expect(result.status).toBe(200) + await vi.advanceTimersByTimeAsync(999) + expect(fetchMock).toHaveBeenCalledTimes(1) + await vi.advanceTimersByTimeAsync(1) + await expect(result).resolves.toMatchObject({ status: 200 }) expect(fetchMock).toHaveBeenCalledTimes(2) - // Clamped by maxDelayMs, so the reset window never stalls the loop. - expect(Date.now() - started).toBeLessThan(1000) }) /** @@ -476,6 +617,83 @@ describe('fetchWithRetry rate-limit handling', () => { }) }) +describe('retryWithExponentialBackoff retry budget', () => { + afterEach(() => { + vi.useRealTimers() + }) + + it('honors an admitted server delay without making an early request', async () => { + vi.useFakeTimers() + const retryable = Object.assign(new Error('rate limited'), { + status: 429, + retryAfterMs: 120, + }) + const operation = vi.fn().mockRejectedValueOnce(retryable).mockResolvedValueOnce('ok') + + const result = retryWithExponentialBackoff(operation, { + maxRetries: 1, + initialDelayMs: 10, + maxDelayMs: 30, + retryBudgetMs: 150, + }) + + await vi.advanceTimersByTimeAsync(119) + expect(operation).toHaveBeenCalledTimes(1) + await vi.advanceTimersByTimeAsync(1) + await expect(result).resolves.toBe('ok') + expect(operation).toHaveBeenCalledTimes(2) + }) + + it('does not retry when the server delay exceeds the operation budget', async () => { + const retryable = Object.assign(new Error('rate limited'), { + status: 429, + retryAfterMs: 120, + }) + const operation = vi.fn().mockRejectedValue(retryable) + + await expect( + retryWithExponentialBackoff(operation, { + maxRetries: 3, + initialDelayMs: 1, + maxDelayMs: 30, + retryBudgetMs: 100, + }) + ).rejects.toThrow('rate limited') + expect(operation).toHaveBeenCalledOnce() + }) + + it('does not treat the legacy per-wait ceiling as a cumulative retry budget', async () => { + vi.useFakeTimers() + const retryable = Object.assign(new Error('service unavailable'), { status: 503 }) + const operation = vi + .fn() + .mockRejectedValueOnce(retryable) + .mockRejectedValueOnce(retryable) + .mockResolvedValueOnce('ok') + + const result = retryWithExponentialBackoff(operation, { + maxRetries: 2, + initialDelayMs: 10, + maxDelayMs: 20, + maxRetryAfterMs: 15, + }) + + await vi.runAllTimersAsync() + await expect(result).resolves.toBe('ok') + expect(operation).toHaveBeenCalledTimes(3) + }) + + it.each([ + { retryBudgetMs: Number.NaN }, + { retryBudgetMs: Number.POSITIVE_INFINITY }, + { maxRetryAfterMs: -1 }, + ])('rejects invalid retry timing options: %o', async (invalid) => { + await expect(retryWithExponentialBackoff(async () => 'ok', invalid)).rejects.toThrow( + /finite non-negative/ + ) + }) +}) + describe('secureFetchWithRetry', () => { beforeEach(() => { mockSecureFetchWithValidation.mockReset() @@ -561,7 +779,7 @@ describe('secureFetchWithRetry', () => { fakeResponse(403, { headers: { 'x-ratelimit-remaining': '0', - 'x-ratelimit-reset': String(Math.floor(Date.now() / 1000) + 1), + 'retry-after': '0.001', }, }) ) @@ -606,4 +824,86 @@ describe('secureFetchWithRetry', () => { expect(response.status).toBe(200) expect(mockSecureFetchWithValidation).toHaveBeenCalledTimes(2) }) + + it('redacts request credentials echoed by a retryable response', async () => { + const accessToken = 'bare-gitlab-token-that-must-not-escape' + mockSecureFetchWithValidation.mockResolvedValue( + new Response(`echo: ${accessToken}`, { status: 503 }) as never + ) + + const error = await secureFetchWithRetry( + 'https://gitlab.example.com/api/v4/projects', + { method: 'GET', headers: { 'PRIVATE-TOKEN': accessToken } }, + { ...FAST_RETRY, maxRetries: 0 } + ).then( + () => undefined, + (caught) => caught as Error + ) + + expect(error?.message).toContain('[response body omitted]') + expect(error?.message).not.toContain(accessToken) + }) + + it('redacts an echoed credential before truncating the diagnostic', async () => { + const accessToken = `opaque-${'x'.repeat(3000)}-tail` + mockSecureFetchWithValidation.mockResolvedValue( + new Response(accessToken, { status: 503 }) as never + ) + + const error = await secureFetchWithRetry( + 'https://gitlab.example.com/api/v4/projects', + { method: 'GET', headers: { 'PRIVATE-TOKEN': accessToken } }, + { ...FAST_RETRY, maxRetries: 0 } + ).then( + () => undefined, + (caught) => caught as Error + ) + + expect(error?.message).toContain('[response body omitted]') + expect(error?.message).not.toContain('opaque-xxxxxxxxxxxxxxxx') + }) + + it.each(['Bearer ', 'Bearer\t', ' Bearer '])( + 'redacts a bare credential separated from its scheme by whitespace: %j', + async (scheme) => { + const accessToken = 'whitespace-separated-token-that-must-not-escape' + mockSecureFetchWithValidation.mockResolvedValue( + new Response(`echo: ${accessToken}`, { status: 503 }) as never + ) + + const error = await secureFetchWithRetry( + 'https://example.com/api', + { method: 'GET', headers: { Authorization: `${scheme}${accessToken}` } }, + { ...FAST_RETRY, maxRetries: 0 } + ).then( + () => undefined, + (caught) => caught as Error + ) + + expect(error?.message).toContain('[response body omitted]') + expect(error?.message).not.toContain(accessToken) + } + ) + + it('redacts both Basic-auth fields using the first colon as the separator', async () => { + const username = 'basic-user-private' + const password = 'basic-password:with:colons' + const authorization = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}` + mockSecureFetchWithValidation.mockResolvedValue( + new Response(`echo: ${username} / ${password}`, { status: 503 }) as never + ) + + const error = await secureFetchWithRetry( + 'https://example.com/api', + { method: 'GET', headers: { Authorization: authorization } }, + { ...FAST_RETRY, maxRetries: 0 } + ).then( + () => undefined, + (caught) => caught as Error + ) + + expect(error?.message).toContain('[response body omitted]') + expect(error?.message).not.toContain(username) + expect(error?.message).not.toContain(password) + }) }) diff --git a/apps/sim/lib/knowledge/documents/utils.ts b/apps/sim/lib/knowledge/documents/utils.ts index 0ac4714af7f..50ad0039f8e 100644 --- a/apps/sim/lib/knowledge/documents/utils.ts +++ b/apps/sim/lib/knowledge/documents/utils.ts @@ -3,6 +3,13 @@ import { toError } from '@sim/utils/errors' import { sleep } from '@sim/utils/helpers' import { randomFloat } from '@sim/utils/random' import { parseRetryAfter } from '@sim/utils/retry' +import { truncate } from '@sim/utils/string' +import { redactSensitiveValues } from '@/lib/core/security/redaction' +import { + DEFAULT_MAX_ERROR_BODY_BYTES, + isPayloadSizeLimitError, + readResponseTextWithLimit, +} from '@/lib/core/utils/stream-limits' const logger = createLogger('RetryUtils') @@ -35,10 +42,70 @@ export interface RetryOptions { maxRetries?: number initialDelayMs?: number maxDelayMs?: number + /** Total wall-clock budget available to waits between retry attempts. */ + retryBudgetMs?: number + /** Longest individual server-stated wait this operation will admit. */ + maxRetryAfterMs?: number backoffMultiplier?: number retryCondition?: (error: unknown) => boolean } +const MAX_HTTP_ERROR_DIAGNOSTIC_CHARS = 2000 +const HTTP_ERROR_BODY_OMITTED = '[response body omitted]' + +/** + * Reads an upstream error body without allowing a provider or proxy error page + * to become an unbounded task error, log entry, or Trigger output. The HTTP + * status remains the retry signal when the body exceeds the byte ceiling. + */ +export async function readBoundedHttpErrorBody(response: { + headers?: { get(name: string): string | null } + body?: ReadableStream | null + arrayBuffer?: () => Promise + text?: () => Promise +}): Promise { + const payload = await readBoundedHttpErrorPayload(response) + if (payload.ok) { + return HTTP_ERROR_BODY_OMITTED + } + + return payload.reason === 'too_large' + ? `[response body omitted: exceeded ${DEFAULT_MAX_ERROR_BODY_BYTES} bytes]` + : '[response body unavailable]' +} + +export type BoundedHttpErrorPayload = + | { ok: true; body: string } + | { ok: false; reason: 'too_large' } + | { ok: false; reason: 'unavailable' } + +/** + * Reads a byte-bounded upstream error payload for structured parsing. The raw + * value may contain secrets and must never be logged or included in an error; + * callers must project and sanitize the parsed fields they retain. + */ +export async function readBoundedHttpErrorPayload(response: { + headers?: { get(name: string): string | null } + body?: ReadableStream | null + arrayBuffer?: () => Promise + text?: () => Promise +}): Promise { + try { + return { + ok: true, + body: await readResponseTextWithLimit(response, { + maxBytes: DEFAULT_MAX_ERROR_BODY_BYTES, + label: 'Upstream HTTP error response', + }), + } + } catch (error) { + if (isPayloadSizeLimitError(error)) { + return { ok: false, reason: 'too_large' } + } + return { ok: false, reason: 'unavailable' } + } +} + interface RetryResult { success: boolean data?: T @@ -152,8 +219,7 @@ function parseRateLimitResetMs(value: string, nowMs: number): number | undefined * The reset fallback applies only once {@link hasRateLimitEvidence} holds. * GitHub and X stamp their rate-limit headers on *every* response, so an * ungated fallback would turn a transient 502 — quota untouched — into a wait - * until the end of the hourly window, which the retry loop then clamps to a - * flat `maxDelayMs` on every attempt instead of climbing the backoff ladder. + * until the end of the hourly window instead of climbing the backoff ladder. * Gating also matches GitHub's own instruction: "If the `x-ratelimit-remaining` * header is `0`, you should not make another request until after the time * specified by the `x-ratelimit-reset` header." @@ -189,8 +255,10 @@ export function isRetryableError(error: unknown): boolean { if (!isRetryableErrorType(error)) return false /** - * Retryable status codes. 529 is not an IANA-registered status, but Notion - * documents it as `service_overload` — "Notion is temporarily overloaded. + * Retryable status codes. Cloudflare documents 520 as an unexpected origin + * response and 522 as an origin connection timeout; both are transient edge + * failures. 529 is not an IANA-registered status, but Notion documents it as + * `service_overload` — "Notion is temporarily overloaded. * Respect the `Retry-After` response header and try again later" — and says * to "retry it the same way as a 429". Without it every Notion call fails * hard the moment their API sheds load. @@ -198,6 +266,8 @@ export function isRetryableError(error: unknown): boolean { if ( hasStatus(error) && (error.status === 429 || + error.status === 520 || + error.status === 522 || error.status === 502 || error.status === 503 || error.status === 504 || @@ -263,9 +333,30 @@ export async function retryWithExponentialBackoff( maxRetries = 5, initialDelayMs = 1000, maxDelayMs = 30000, + retryBudgetMs, backoffMultiplier = 2, retryCondition = isRetryableError, } = options + const maxRetryAfterMs = options.maxRetryAfterMs ?? retryBudgetMs ?? maxDelayMs + + if (!Number.isSafeInteger(maxRetries) || maxRetries < 0) { + throw new Error('Retry maxRetries must be a non-negative safe integer') + } + for (const [name, value] of [ + ['initialDelayMs', initialDelayMs], + ['maxDelayMs', maxDelayMs], + ['maxRetryAfterMs', maxRetryAfterMs], + ...(retryBudgetMs === undefined ? [] : [['retryBudgetMs', retryBudgetMs] as const]), + ] as const) { + if (!Number.isFinite(value) || value < 0) { + throw new Error(`Retry ${name} must be a finite non-negative number`) + } + } + if (!Number.isFinite(backoffMultiplier) || backoffMultiplier <= 0) { + throw new Error('Retry backoffMultiplier must be a finite positive number') + } + const effectiveRetryBudgetMs = retryBudgetMs ?? maxRetries * Math.max(maxDelayMs, maxRetryAfterMs) + const retryDeadlineMs = Date.now() + effectiveRetryBudgetMs let lastError: Error | undefined let delay = initialDelayMs @@ -282,50 +373,65 @@ export async function retryWithExponentialBackoff( return result } catch (error) { lastError = toError(error) - logger.warn(`Operation failed on attempt ${attempt + 1}`, { error }) + const retryableError = error as RetryableError + const safeError = { + error: truncate(redactSensitiveValues(lastError.message), MAX_HTTP_ERROR_DIAGNOSTIC_CHARS), + ...(hasStatus(retryableError) ? { status: retryableError.status } : {}), + } + logger.warn(`Operation failed on attempt ${attempt + 1}`, safeError) if (attempt === maxRetries) { - logger.error(`Operation failed after ${maxRetries + 1} attempts`, { error }) + logger.error(`Operation failed after ${maxRetries + 1} attempts`, safeError) throw lastError } if (!retryCondition(error as RetryableError)) { - logger.warn('Error is not retryable, throwing immediately', { error }) + logger.warn('Error is not retryable, throwing immediately', safeError) throw lastError } /** * Use the server-stated wait (Retry-After, or the rate-limit reset - * header) when present, otherwise exponential backoff. The wait is capped - * at maxDelayMs to bound total retry duration. + * header) when present, otherwise exponential backoff. * - * Note the tradeoff the cap creates for long rate-limit windows: GitHub's - * primary window is an hour and X's is 15 minutes, both far beyond the - * 30s default, so every retry fires before the window reopens and the - * attempts are spent for nothing. Raising the cap would instead stall a - * sync for the full window. Neither provider documents a bound here, so - * the existing conservative cap stands and the mismatch is logged. + * A server-stated wait is authoritative when it fits inside the remaining + * operation budget. It is never shortened into an early request that the + * provider explicitly told us not to make. */ const retryAfterMs = (lastError as HTTPError)?.retryAfterMs - const cappedRetryAfter = retryAfterMs ? Math.min(retryAfterMs, maxDelayMs) : undefined - if (retryAfterMs && retryAfterMs > maxDelayMs) { + const remainingBudgetMs = Math.max(0, retryDeadlineMs - Date.now()) + if (retryAfterMs && retryAfterMs > maxRetryAfterMs) { + logger.warn( + `Server-stated retry wait ${retryAfterMs}ms exceeds per-wait ceiling ${maxRetryAfterMs}ms — ending this retry cycle` + ) + throw lastError + } + if (retryAfterMs && retryAfterMs > remainingBudgetMs) { logger.warn( - `Server-stated retry wait ${retryAfterMs}ms exceeds maxDelayMs ${maxDelayMs}ms — capping to ${maxDelayMs}ms; retries will fire before the rate-limit window reopens` + `Server-stated retry wait ${retryAfterMs}ms exceeds remaining retry budget ${remainingBudgetMs}ms — ending this retry cycle` ) + throw lastError } const jitter = randomFloat() * 0.1 * delay - const actualDelay = cappedRetryAfter ?? Math.min(delay + jitter, maxDelayMs) + const actualDelay = retryAfterMs ? retryAfterMs : Math.min(delay + jitter, maxDelayMs) + + if (actualDelay > remainingBudgetMs) { + logger.warn( + `Retry delay ${Math.round(actualDelay)}ms exceeds remaining retry budget ${Math.round(remainingBudgetMs)}ms — ending this retry cycle` + ) + throw lastError + } logger.info( - `Retrying in ${Math.round(actualDelay)}ms (attempt ${attempt + 1}/${maxRetries + 1})${cappedRetryAfter ? ' (server-stated)' : ''}` + `Retrying in ${Math.round(actualDelay)}ms (attempt ${attempt + 1}/${maxRetries + 1})${retryAfterMs ? ' (server-stated)' : ''}` ) await sleep(actualDelay) // Exponential backoff (skip if we used Retry-After) - if (!cappedRetryAfter) { + if (!retryAfterMs) { delay = Math.min(delay * backoffMultiplier, maxDelayMs) } } @@ -356,12 +462,9 @@ export async function fetchWithRetry( const response = await fetch(url, options) if (!response.ok && isRetryableError({ status: response.status, headers: response.headers })) { - const errorText = await response.text() - const error: HTTPError = new Error( - `HTTP ${response.status}: ${response.statusText} - ${errorText}` - ) + const errorText = await readBoundedHttpErrorBody(response) + const error: HTTPError = new Error(`HTTP ${response.status} - ${errorText}`) error.status = response.status - error.statusText = response.statusText // The retry loop re-runs the retry condition against this error, so the // headers must travel with it or a rate-limit 403 would throw immediately. attachRetryHeaders(error, response.headers) diff --git a/apps/sim/lib/knowledge/embedding-models.ts b/apps/sim/lib/knowledge/embedding-models.ts index b965570965b..83dee5e3063 100644 --- a/apps/sim/lib/knowledge/embedding-models.ts +++ b/apps/sim/lib/knowledge/embedding-models.ts @@ -27,6 +27,8 @@ export interface EmbeddingModelInfo { pricingId: string /** Provider id for `estimateTokenCount` so token counts match the embedding provider's tokenization. */ tokenizerProvider: TokenizerProviderId + /** Maximum tokens accepted for one embedding input by the selected model. */ + maxInputTokens: number } export const SUPPORTED_EMBEDDING_MODELS: Partial> = @@ -39,6 +41,7 @@ export const SUPPORTED_EMBEDDING_MODELS: Partial { it('refuses to time out a document that is not processing', async () => { const outcome = await performMarkKnowledgeDocumentTimedOut({ + knowledgeBaseId: 'kb-1', document: { id: 'doc-1', processingStatus: 'completed', processingStartedAt: new Date() }, }) @@ -455,6 +456,7 @@ describe('document processing state changes', () => { it('refuses to time out a document with no processing start time', async () => { const outcome = await performMarkKnowledgeDocumentTimedOut({ + knowledgeBaseId: 'kb-1', document: { id: 'doc-1', processingStatus: 'processing', processingStartedAt: null }, }) @@ -467,6 +469,7 @@ describe('document processing state changes', () => { ) const outcome = await performMarkKnowledgeDocumentTimedOut({ + knowledgeBaseId: 'kb-1', document: { id: 'doc-1', processingStatus: 'processing', processingStartedAt: new Date() }, }) @@ -480,6 +483,7 @@ describe('document processing state changes', () => { }) const outcome = await performMarkKnowledgeDocumentTimedOut({ + knowledgeBaseId: 'kb-1', document: { id: 'doc-1', processingStatus: 'processing', processingStartedAt: new Date() }, }) diff --git a/apps/sim/lib/knowledge/orchestration/documents.ts b/apps/sim/lib/knowledge/orchestration/documents.ts index 4e856a729ea..66d1d101c5c 100644 --- a/apps/sim/lib/knowledge/orchestration/documents.ts +++ b/apps/sim/lib/knowledge/orchestration/documents.ts @@ -492,6 +492,7 @@ export async function performDeleteKnowledgeDocument( } export interface PerformMarkKnowledgeDocumentTimedOutParams { + knowledgeBaseId: string document: { id: string processingStatus: string @@ -512,7 +513,7 @@ export type PerformKnowledgeDocumentProcessingResult = KnowledgeOrchestrationRes export async function performMarkKnowledgeDocumentTimedOut( params: PerformMarkKnowledgeDocumentTimedOutParams ): Promise { - const { document } = params + const { document, knowledgeBaseId } = params const requestId = params.requestId ?? generateRequestId() if (document.processingStatus !== 'processing') { @@ -527,6 +528,7 @@ export async function performMarkKnowledgeDocumentTimedOut( try { const result = await markDocumentAsFailedTimeout( + knowledgeBaseId, document.id, document.processingStartedAt, requestId diff --git a/apps/sim/lib/oauth/oauth.test.ts b/apps/sim/lib/oauth/oauth.test.ts index dd414a9ec64..7b775d487fb 100644 --- a/apps/sim/lib/oauth/oauth.test.ts +++ b/apps/sim/lib/oauth/oauth.test.ts @@ -90,7 +90,23 @@ const defaultOAuthResponse = { */ function withMockFetch(mockFetch: ReturnType, fn: () => Promise): Promise { const originalFetch = global.fetch - global.fetch = mockFetch + global.fetch = vi.fn(async (input: string | URL | Request, init?: RequestInit) => { + const mocked = (await mockFetch(input, init)) as Partial + if (mocked instanceof Response && mocked.body) return mocked + + let bodyText = '' + if (typeof mocked.text === 'function') { + bodyText = await mocked.text() + } else if (typeof mocked.json === 'function') { + bodyText = JSON.stringify(await mocked.json()) + } + + return new Response(bodyText, { + status: mocked.status ?? 200, + statusText: mocked.statusText, + headers: mocked.headers, + }) + }) return fn().finally(() => { global.fetch = originalFetch }) @@ -665,6 +681,62 @@ describe('OAuth Token Refresh', () => { } }) + it.concurrent( + 'should redact literal and encoded credentials echoed by a provider', + async () => { + const refreshToken = 'refresh/with space' + const formEncodedRefreshToken = new URLSearchParams({ value: refreshToken }) + .toString() + .slice('value='.length) + const mockFetch = vi + .fn() + .mockResolvedValue( + new Response( + `provider echo: ${refreshToken}, ${encodeURIComponent(refreshToken)}, ${formEncodedRefreshToken}, and google_client_secret`, + { status: 400 } + ) + ) + + const result = await withMockFetch(mockFetch, () => + refreshOAuthToken('google', refreshToken) + ) + + expect(result.ok).toBe(false) + if (!result.ok) { + expect(result.message).not.toContain(refreshToken) + expect(result.message).not.toContain(encodeURIComponent(refreshToken)) + expect(result.message).not.toContain(formEncodedRefreshToken) + expect(result.message).not.toContain('google_client_secret') + } + } + ) + + it.concurrent('should redact a secret from a successful HTTP body error', async () => { + const refreshToken = 'slack-refresh-secret' + const mockFetch = vi + .fn() + .mockResolvedValue(Response.json({ ok: false, error: `invalid_${refreshToken}` })) + + const result = await withMockFetch(mockFetch, () => refreshOAuthToken('slack', refreshToken)) + + expect(result.ok).toBe(false) + if (!result.ok) { + expect(result.message).not.toContain(refreshToken) + expect(result.errorCode).toBeUndefined() + } + }) + + it.concurrent('uses canonical endpoints without following credential redirects', async () => { + const mockFetch = createMockFetch(defaultOAuthResponse) + + await withMockFetch(mockFetch, () => refreshOAuthToken('google', 'test_refresh_token')) + + expect(mockFetch).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ redirect: 'error' }) + ) + }) + it.concurrent('should return failure for network errors', async () => { const mockFetch = vi.fn().mockRejectedValue(new Error('Network error')) const refreshToken = 'test_refresh_token' @@ -673,9 +745,40 @@ describe('OAuth Token Refresh', () => { expect(result.ok).toBe(false) }) + + it.concurrent( + 'should reject oversized OAuth error responses without materializing them', + async () => { + const mockFetch = vi + .fn() + .mockResolvedValue( + new Response('x'.repeat(DEFAULT_MAX_ERROR_BODY_BYTES + 1), { status: 400 }) + ) + + const result = await withMockFetch(mockFetch, () => + refreshOAuthToken('google', 'test_refresh_token') + ) + + expect(result.ok).toBe(false) + if (!result.ok) expect(result.message).toContain('exceeds maximum size') + } + ) }) describe('Token Response Handling', () => { + it.concurrent('should bound successful token responses before parsing them', async () => { + const mockFetch = vi + .fn() + .mockResolvedValue(new Response('x'.repeat(DEFAULT_MAX_ERROR_BODY_BYTES + 1))) + + const result = await withMockFetch(mockFetch, () => + refreshOAuthToken('google', 'test_refresh_token') + ) + + expect(result.ok).toBe(false) + if (!result.ok) expect(result.message).toContain('exceeds maximum size') + }) + it.concurrent('should handle providers that return new refresh tokens', async () => { const refreshToken = 'old_refresh_token' const newRefreshToken = 'new_refresh_token' diff --git a/apps/sim/lib/oauth/oauth.ts b/apps/sim/lib/oauth/oauth.ts index 258cd3b3dc1..a8d07519aaa 100644 --- a/apps/sim/lib/oauth/oauth.ts +++ b/apps/sim/lib/oauth/oauth.ts @@ -1,6 +1,5 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' -import { truncate } from '@sim/utils/string' import { AirtableIcon, AsanaIcon, @@ -72,6 +71,7 @@ import { requireOAuthClientCapability, } from '@/lib/core/config/env-capabilities' import { isSlackExtendedScopesEnabled } from '@/lib/core/config/env-flags' +import { redactExactSensitiveValues } from '@/lib/core/security/redaction' import { DEFAULT_MAX_ERROR_BODY_BYTES, readResponseTextWithLimit, @@ -2026,6 +2026,13 @@ function extractErrorCode(value: unknown): string | undefined { return undefined } +function safeOAuthErrorCode(value: unknown, secrets: string[]): string | undefined { + const errorCode = extractErrorCode(value) + if (!errorCode) return undefined + const safeCode = redactExactSensitiveValues(errorCode, secrets).trim().toLowerCase() + return /^[a-z0-9][a-z0-9._:-]{0,127}$/.test(safeCode) ? safeCode : undefined +} + /** * Hard deadline on the token-endpoint exchange. This function does not coalesce * on its own; its sole production caller (`performCoalescedRefresh` in the OAuth @@ -2035,6 +2042,22 @@ function extractErrorCode(value: unknown): string | undefined { */ const TOKEN_REFRESH_TIMEOUT_MS = 15_000 +function parseOAuthResponse(responseText: string): unknown { + try { + return JSON.parse(responseText) + } catch { + return responseText + } +} + +function oauthResponseRecord(value: unknown): Record | undefined { + return value && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : undefined +} + +const OAUTH_RESPONSE_OMITTED = '[token endpoint response omitted]' + async function refreshInstagramLongLivedToken( config: ProviderAuthConfig, longLivedToken: string, @@ -2046,6 +2069,7 @@ async function refreshInstagramLongLivedToken( const response = await fetch(url.toString(), { method: 'GET', + redirect: 'error', signal: AbortSignal.timeout(TOKEN_REFRESH_TIMEOUT_MS), }) @@ -2053,27 +2077,22 @@ async function refreshInstagramLongLivedToken( maxBytes: DEFAULT_MAX_ERROR_BODY_BYTES, label: 'Instagram token refresh response', }) - let responseData: unknown = responseText - try { - responseData = JSON.parse(responseText) - } catch { - responseData = responseText - } + const responseData = parseOAuthResponse(responseText) if (!response.ok) { - const errorSummary = truncate(responseText, 1000) + const exactSecrets = [longLivedToken, config.clientSecret ?? ''] + const errorCode = safeOAuthErrorCode(responseData, exactSecrets) logger.error('Instagram long-lived token refresh failed:', { status: response.status, - statusText: response.statusText, - error: errorSummary, - parsedError: responseData, + error: OAUTH_RESPONSE_OMITTED, + errorCode, providerId, tokenEndpoint: config.tokenEndpoint, }) return { ok: false, - errorCode: extractErrorCode(responseData), - message: `Failed to refresh token: ${response.status} ${errorSummary}`, + errorCode, + message: `Failed to refresh token: ${response.status} ${OAUTH_RESPONSE_OMITTED}`, } } @@ -2101,10 +2120,12 @@ export async function refreshOAuthToken( providerId: string, refreshToken: string ): Promise { + const exactSecrets = [refreshToken] try { const provider = getBaseProviderForService(providerId) const config = getProviderAuthConfig(provider) + if (config.clientSecret) exactSecrets.push(config.clientSecret) if (config.refreshStrategy === 'instagram_long_lived') { return await refreshInstagramLongLivedToken(config, refreshToken, providerId) @@ -2116,24 +2137,23 @@ export async function refreshOAuthToken( method: 'POST', headers, body: useJsonBody ? JSON.stringify(bodyParams) : new URLSearchParams(bodyParams).toString(), + redirect: 'error', signal: AbortSignal.timeout(TOKEN_REFRESH_TIMEOUT_MS), }) - if (!response.ok) { - const errorText = await response.text() - let errorData: unknown = errorText + const responseText = await readResponseTextWithLimit(response, { + maxBytes: DEFAULT_MAX_ERROR_BODY_BYTES, + label: 'OAuth token refresh response', + }) + const responseData = parseOAuthResponse(responseText) - try { - errorData = JSON.parse(errorText) - } catch (_e) { - // Not JSON, keep as text - } + if (!response.ok) { + const errorCode = safeOAuthErrorCode(responseData, exactSecrets) logger.error('Token refresh failed:', { status: response.status, - statusText: response.statusText, - error: errorText, - parsedError: errorData, + error: OAUTH_RESPONSE_OMITTED, + errorCode, providerId, tokenEndpoint: config.tokenEndpoint, hasClientId: !!config.clientId, @@ -2142,19 +2162,23 @@ export async function refreshOAuthToken( }) return { ok: false, - errorCode: extractErrorCode(errorData), - message: `Failed to refresh token: ${response.status} ${errorText}`, + errorCode, + message: `Failed to refresh token: ${response.status} ${OAUTH_RESPONSE_OMITTED}`, } } - const data = await response.json() + const data = oauthResponseRecord(responseData) + if (!data) { + logger.warn('Invalid OAuth token refresh response', { providerId }) + return { ok: false, message: 'Invalid OAuth token refresh response' } + } - if (data && typeof data === 'object' && data.ok === false) { + if (data.ok === false) { + const errorCode = safeOAuthErrorCode(data, exactSecrets) logger.error('Token refresh failed:', { status: response.status, - statusText: response.statusText, - error: data.error, - parsedError: data, + error: OAUTH_RESPONSE_OMITTED, + errorCode, providerId, tokenEndpoint: config.tokenEndpoint, hasClientId: !!config.clientId, @@ -2163,20 +2187,33 @@ export async function refreshOAuthToken( }) return { ok: false, - errorCode: typeof data.error === 'string' ? data.error : undefined, - message: `Failed to refresh token: ${data.error ?? 'unknown'}`, + errorCode, + message: `Failed to refresh token: ${OAUTH_RESPONSE_OMITTED}`, } } - const accessToken = data.access_token + const accessToken = + typeof data.access_token === 'string' && data.access_token.length > 0 + ? data.access_token + : undefined - let newRefreshToken = null - if (config.supportsRefreshTokenRotation && data.refresh_token) { + let newRefreshToken: string | undefined + if ( + config.supportsRefreshTokenRotation && + typeof data.refresh_token === 'string' && + data.refresh_token.length > 0 + ) { newRefreshToken = data.refresh_token logger.info(`Received new refresh token from ${provider}`) } - const expiresIn = data.expires_in || data.expiresIn || 3600 + const rawExpiresIn = data.expires_in ?? data.expiresIn + const parsedExpiresIn = + typeof rawExpiresIn === 'number' || typeof rawExpiresIn === 'string' + ? Number(rawExpiresIn) + : Number.NaN + const expiresIn = + Number.isFinite(parsedExpiresIn) && parsedExpiresIn > 0 ? parsedExpiresIn : 3600 if (!accessToken) { // Log only the shape, never `data` itself - on a partial success it can @@ -2198,11 +2235,15 @@ export async function refreshOAuthToken( ok: true, accessToken, expiresIn, - refreshToken: newRefreshToken || refreshToken, // Return new refresh token if available + refreshToken: newRefreshToken ?? refreshToken, } } catch (error) { - const message = toError(error).message - logger.error('Error refreshing token:', { error: message }) + const normalized = toError(error) + const message = + normalized.name === 'PayloadSizeLimitError' || normalized.message.startsWith('OAuth client ') + ? normalized.message + : 'Token refresh failed' + logger.error('Error refreshing token', { errorType: normalized.name }) return { ok: false, message } } } diff --git a/apps/sim/tools/mistral/parser.ts b/apps/sim/tools/mistral/parser.ts index 86fa1a038c4..9bcca4b0ea6 100644 --- a/apps/sim/tools/mistral/parser.ts +++ b/apps/sim/tools/mistral/parser.ts @@ -20,8 +20,8 @@ const logger = createLogger('MistralParserTool') * Mistral OCR 4 standard pricing, in USD per page ($4 per 1,000 pages). * * This tool calls the synchronous `/v1/ocr` endpoint with the `mistral-ocr-latest` - * alias, which Mistral repointed to OCR 4 (`mistral-ocr-4-0`) on 2026-06-23, so the - * standard (non-batch) OCR 4 rate applies. Document AI / annotation pages are priced + * alias, which currently resolves within the OCR 4 family, so the standard + * non-batch OCR 4 rate applies. Document AI / annotation pages are priced * separately, but this tool does not submit annotation requests. * * @see https://mistral.ai/news/ocr-4/ @@ -287,8 +287,8 @@ export const mistralParserTool: ToolConfig statement-breakpoint +-- migration-safe: additive nullable quota-deferral metadata is ignored by released application versions. +ALTER TABLE "document" ADD COLUMN "processing_deferred_until" timestamp;--> statement-breakpoint +-- migration-safe: the additive non-null counter has a constant default and preserves the released zero-skips behavior. +ALTER TABLE "knowledge_connector_sync_log" ADD COLUMN "docs_skipped" integer DEFAULT 0 NOT NULL; diff --git a/packages/db/migrations/meta/0306_snapshot.json b/packages/db/migrations/meta/0306_snapshot.json new file mode 100644 index 00000000000..58366537e81 --- /dev/null +++ b/packages/db/migrations/meta/0306_snapshot.json @@ -0,0 +1,20139 @@ +{ + "id": "ae824749-47cc-460d-80c1-aec58f0a8480", + "prevId": "10267de3-7569-40c6-895c-800b736a161e", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.academy_certificate": { + "name": "academy_certificate", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "course_id": { + "name": "course_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "academy_cert_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "issued_at": { + "name": "issued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "certificate_number": { + "name": "certificate_number", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "academy_certificate_user_id_idx": { + "name": "academy_certificate_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_course_id_idx": { + "name": "academy_certificate_course_id_idx", + "columns": [ + { + "expression": "course_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_user_course_unique": { + "name": "academy_certificate_user_course_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "course_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_status_idx": { + "name": "academy_certificate_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "academy_certificate_user_id_user_id_fk": { + "name": "academy_certificate_user_id_user_id_fk", + "tableFrom": "academy_certificate", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "academy_certificate_certificate_number_unique": { + "name": "academy_certificate_certificate_number_unique", + "nullsNotDistinct": false, + "columns": ["certificate_number"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_user_id_idx": { + "name": "account_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_account_on_account_id_provider_id": { + "name": "idx_account_on_account_id_provider_id", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_key": { + "name": "api_key", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'personal'" + }, + "last_used": { + "name": "last_used", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "api_key_workspace_type_idx": { + "name": "api_key_workspace_type_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_key_user_type_idx": { + "name": "api_key_user_type_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_key_key_hash_idx": { + "name": "api_key_key_hash_idx", + "columns": [ + { + "expression": "key_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "api_key_user_id_user_id_fk": { + "name": "api_key_user_id_user_id_fk", + "tableFrom": "api_key", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "api_key_workspace_id_workspace_id_fk": { + "name": "api_key_workspace_id_workspace_id_fk", + "tableFrom": "api_key", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "api_key_created_by_user_id_fk": { + "name": "api_key_created_by_user_id_fk", + "tableFrom": "api_key", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "api_key_key_unique": { + "name": "api_key_key_unique", + "nullsNotDistinct": false, + "columns": ["key"] + } + }, + "policies": {}, + "checkConstraints": { + "workspace_type_check": { + "name": "workspace_type_check", + "value": "(type = 'workspace' AND workspace_id IS NOT NULL) OR (type = 'personal' AND workspace_id IS NULL)" + } + }, + "isRLSEnabled": false + }, + "public.async_jobs": { + "name": "async_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "run_at": { + "name": "run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "max_attempts": { + "name": "max_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 3 + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "output": { + "name": "output", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "async_jobs_status_started_at_idx": { + "name": "async_jobs_status_started_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_status_completed_at_idx": { + "name": "async_jobs_status_completed_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "completed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_schedule_pending_run_at_idx": { + "name": "async_jobs_schedule_pending_run_at_idx", + "columns": [ + { + "expression": "run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"async_jobs\".\"type\" = 'schedule-execution' AND \"async_jobs\".\"status\" = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_schedule_processing_started_at_idx": { + "name": "async_jobs_schedule_processing_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"async_jobs\".\"type\" = 'schedule-execution' AND \"async_jobs\".\"status\" = 'processing'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_schedule_unreconciled_terminal_idx": { + "name": "async_jobs_schedule_unreconciled_terminal_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"async_jobs\".\"type\" = 'schedule-execution' AND \"async_jobs\".\"status\" IN ('completed', 'failed', 'cancelled') AND COALESCE(\"async_jobs\".\"metadata\" ->> 'scheduleReconciled', 'false') <> 'true'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_log": { + "name": "audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_name": { + "name": "actor_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_email": { + "name": "actor_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resource_name": { + "name": "resource_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_log_workspace_created_idx": { + "name": "audit_log_workspace_created_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_workspace_created_at_id_idx": { + "name": "audit_log_workspace_created_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"created_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_actor_created_idx": { + "name": "audit_log_actor_created_idx", + "columns": [ + { + "expression": "actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_resource_idx": { + "name": "audit_log_resource_idx", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_action_idx": { + "name": "audit_log_action_idx", + "columns": [ + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_log_workspace_id_workspace_id_fk": { + "name": "audit_log_workspace_id_workspace_id_fk", + "tableFrom": "audit_log", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "audit_log_actor_id_user_id_fk": { + "name": "audit_log_actor_id_user_id_fk", + "tableFrom": "audit_log", + "tableTo": "user", + "columnsFrom": ["actor_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.background_work_status": { + "name": "background_work_status", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "background_work_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "background_work_status_value", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "background_work_status_workspace_status_idx": { + "name": "background_work_status_workspace_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "background_work_status_workflow_status_idx": { + "name": "background_work_status_workflow_status_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "background_work_status_meta_child_ws_idx": { + "name": "background_work_status_meta_child_ws_idx", + "columns": [ + { + "expression": "(\"metadata\" ->> 'childWorkspaceId')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "background_work_status_meta_other_ws_idx": { + "name": "background_work_status_meta_other_ws_idx", + "columns": [ + { + "expression": "(\"metadata\" ->> 'otherWorkspaceId')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "background_work_status_workspace_id_workspace_id_fk": { + "name": "background_work_status_workspace_id_workspace_id_fk", + "tableFrom": "background_work_status", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "background_work_status_workflow_id_workflow_id_fk": { + "name": "background_work_status_workflow_id_workflow_id_fk", + "tableFrom": "background_work_status", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat": { + "name": "chat", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "customizations": { + "name": "customizations", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowed_emails": { + "name": "allowed_emails", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "output_configs": { + "name": "output_configs", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "include_thinking": { + "name": "include_thinking", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "include_tool_calls": { + "name": "include_tool_calls", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "identifier_idx": { + "name": "identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"chat\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_archived_at_partial_idx": { + "name": "chat_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"chat\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_chat_on_workflow_id_archived_at": { + "name": "idx_chat_on_workflow_id_archived_at", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_workflow_id_workflow_id_fk": { + "name": "chat_workflow_id_workflow_id_fk", + "tableFrom": "chat", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chat_user_id_user_id_fk": { + "name": "chat_user_id_user_id_fk", + "tableFrom": "chat", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_async_tool_calls": { + "name": "copilot_async_tool_calls", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "checkpoint_id": { + "name": "checkpoint_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "tool_call_id": { + "name": "tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "args": { + "name": "args", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "status": { + "name": "status", + "type": "copilot_async_tool_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "permission_decision": { + "name": "permission_decision", + "type": "copilot_tool_permission_decision", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "permission_decided_at": { + "name": "permission_decided_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "claimed_by": { + "name": "claimed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_async_tool_calls_run_id_idx": { + "name": "copilot_async_tool_calls_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_checkpoint_id_idx": { + "name": "copilot_async_tool_calls_checkpoint_id_idx", + "columns": [ + { + "expression": "checkpoint_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_status_idx": { + "name": "copilot_async_tool_calls_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_run_status_idx": { + "name": "copilot_async_tool_calls_run_status_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_tool_call_id_unique": { + "name": "copilot_async_tool_calls_tool_call_id_unique", + "columns": [ + { + "expression": "tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_async_tool_calls_run_id_copilot_runs_id_fk": { + "name": "copilot_async_tool_calls_run_id_copilot_runs_id_fk", + "tableFrom": "copilot_async_tool_calls", + "tableTo": "copilot_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_async_tool_calls_checkpoint_id_copilot_run_checkpoints_id_fk": { + "name": "copilot_async_tool_calls_checkpoint_id_copilot_run_checkpoints_id_fk", + "tableFrom": "copilot_async_tool_calls", + "tableTo": "copilot_run_checkpoints", + "columnsFrom": ["checkpoint_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_chats": { + "name": "copilot_chats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "chat_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'copilot'" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'claude-3-7-sonnet-latest'" + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "preview_yaml": { + "name": "preview_yaml", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "plan_artifact": { + "name": "plan_artifact", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "resources": { + "name": "resources", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "auto_allowed_tools": { + "name": "auto_allowed_tools", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "pinned": { + "name": "pinned", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_chats_user_id_idx": { + "name": "copilot_chats_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_workflow_id_idx": { + "name": "copilot_chats_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_user_workflow_idx": { + "name": "copilot_chats_user_workflow_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_user_workspace_idx": { + "name": "copilot_chats_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_created_at_idx": { + "name": "copilot_chats_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_updated_at_idx": { + "name": "copilot_chats_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_workspace_created_at_id_idx": { + "name": "copilot_chats_workspace_created_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"created_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_user_workspace_deleted_partial_idx": { + "name": "copilot_chats_user_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_chats\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_chats_user_id_user_id_fk": { + "name": "copilot_chats_user_id_user_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_chats_workflow_id_workflow_id_fk": { + "name": "copilot_chats_workflow_id_workflow_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_chats_workspace_id_workspace_id_fk": { + "name": "copilot_chats_workspace_id_workspace_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_feedback": { + "name": "copilot_feedback", + "schema": "", + "columns": { + "feedback_id": { + "name": "feedback_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_query": { + "name": "user_query", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_response": { + "name": "agent_response", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_positive": { + "name": "is_positive", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "feedback": { + "name": "feedback", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_yaml": { + "name": "workflow_yaml", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_feedback_user_id_idx": { + "name": "copilot_feedback_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_chat_id_idx": { + "name": "copilot_feedback_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_user_chat_idx": { + "name": "copilot_feedback_user_chat_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_is_positive_idx": { + "name": "copilot_feedback_is_positive_idx", + "columns": [ + { + "expression": "is_positive", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_created_at_idx": { + "name": "copilot_feedback_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_feedback_user_id_user_id_fk": { + "name": "copilot_feedback_user_id_user_id_fk", + "tableFrom": "copilot_feedback", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_feedback_chat_id_copilot_chats_id_fk": { + "name": "copilot_feedback_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_feedback", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_messages": { + "name": "copilot_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "stream_id": { + "name": "stream_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parent_message_id": { + "name": "parent_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tokens_in": { + "name": "tokens_in", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "tokens_out": { + "name": "tokens_out", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_messages_chat_message_unique": { + "name": "copilot_messages_chat_message_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_chat_created_at_idx": { + "name": "copilot_messages_chat_created_at_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_chat_seq_idx": { + "name": "copilot_messages_chat_seq_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "seq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_chat_stream_idx": { + "name": "copilot_messages_chat_stream_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "stream_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"stream_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_user_created_at_idx": { + "name": "copilot_messages_user_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"role\" = 'user' AND \"copilot_messages\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_messages_chat_id_copilot_chats_id_fk": { + "name": "copilot_messages_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_messages", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_run_checkpoints": { + "name": "copilot_run_checkpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "pending_tool_call_id": { + "name": "pending_tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conversation_snapshot": { + "name": "conversation_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "agent_state": { + "name": "agent_state", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "provider_request": { + "name": "provider_request", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_run_checkpoints_run_id_idx": { + "name": "copilot_run_checkpoints_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_run_checkpoints_pending_tool_call_id_idx": { + "name": "copilot_run_checkpoints_pending_tool_call_id_idx", + "columns": [ + { + "expression": "pending_tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_run_checkpoints_run_pending_tool_unique": { + "name": "copilot_run_checkpoints_run_pending_tool_unique", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pending_tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_run_checkpoints_run_id_copilot_runs_id_fk": { + "name": "copilot_run_checkpoints_run_id_copilot_runs_id_fk", + "tableFrom": "copilot_run_checkpoints", + "tableTo": "copilot_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_runs": { + "name": "copilot_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_run_id": { + "name": "parent_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stream_id": { + "name": "stream_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent": { + "name": "agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "copilot_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "request_context": { + "name": "request_context", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "copilot_runs_execution_id_idx": { + "name": "copilot_runs_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_parent_run_id_idx": { + "name": "copilot_runs_parent_run_id_idx", + "columns": [ + { + "expression": "parent_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_chat_id_idx": { + "name": "copilot_runs_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_user_id_idx": { + "name": "copilot_runs_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_workflow_id_idx": { + "name": "copilot_runs_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_workspace_id_idx": { + "name": "copilot_runs_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_status_idx": { + "name": "copilot_runs_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_chat_execution_idx": { + "name": "copilot_runs_chat_execution_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_execution_started_at_idx": { + "name": "copilot_runs_execution_started_at_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_workspace_completed_at_id_idx": { + "name": "copilot_runs_workspace_completed_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"completed_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_stream_id_unique": { + "name": "copilot_runs_stream_id_unique", + "columns": [ + { + "expression": "stream_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_runs_chat_id_copilot_chats_id_fk": { + "name": "copilot_runs_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_user_id_user_id_fk": { + "name": "copilot_runs_user_id_user_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_workflow_id_workflow_id_fk": { + "name": "copilot_runs_workflow_id_workflow_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_workspace_id_workspace_id_fk": { + "name": "copilot_runs_workspace_id_workspace_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_workflow_read_hashes": { + "name": "copilot_workflow_read_hashes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_workflow_read_hashes_chat_id_idx": { + "name": "copilot_workflow_read_hashes_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_workflow_read_hashes_workflow_id_idx": { + "name": "copilot_workflow_read_hashes_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_workflow_read_hashes_chat_workflow_unique": { + "name": "copilot_workflow_read_hashes_chat_workflow_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_workflow_read_hashes_chat_id_copilot_chats_id_fk": { + "name": "copilot_workflow_read_hashes_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_workflow_read_hashes", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_workflow_read_hashes_workflow_id_workflow_id_fk": { + "name": "copilot_workflow_read_hashes_workflow_id_workflow_id_fk", + "tableFrom": "copilot_workflow_read_hashes", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credential": { + "name": "credential", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "credential_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "unredacted": { + "name": "unredacted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env_key": { + "name": "env_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env_owner_user_id": { + "name": "env_owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_service_account_key": { + "name": "encrypted_service_account_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "authorization_app_id": { + "name": "authorization_app_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_group_enrollment_id": { + "name": "credential_group_enrollment_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_group_option_id": { + "name": "credential_group_option_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "managed_oauth_scope_version": { + "name": "managed_oauth_scope_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "provider_subject_id": { + "name": "provider_subject_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_tenant_id": { + "name": "provider_tenant_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "managed_oauth_status": { + "name": "managed_oauth_status", + "type": "managed_oauth_credential_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "granted_scopes": { + "name": "granted_scopes", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "provider_metadata": { + "name": "provider_metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "encrypted_oauth_token_set": { + "name": "encrypted_oauth_token_set", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "granted_at": { + "name": "granted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_refreshed_at": { + "name": "last_refreshed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_workspace_id_idx": { + "name": "credential_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_type_idx": { + "name": "credential_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_provider_id_idx": { + "name": "credential_provider_id_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_account_id_idx": { + "name": "credential_account_id_idx", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_env_owner_user_id_idx": { + "name": "credential_env_owner_user_id_idx", + "columns": [ + { + "expression": "env_owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_group_enrollment_idx": { + "name": "credential_group_enrollment_idx", + "columns": [ + { + "expression": "credential_group_enrollment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_group_option_unique": { + "name": "credential_group_option_unique", + "columns": [ + { + "expression": "credential_group_enrollment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "credential_group_option_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"credential\".\"type\" = 'managed_oauth'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_account_unique": { + "name": "credential_workspace_account_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "account_id IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_env_unique": { + "name": "credential_workspace_env_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "type = 'env_workspace'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_personal_env_unique": { + "name": "credential_workspace_personal_env_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "type = 'env_personal'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_workspace_id_workspace_id_fk": { + "name": "credential_workspace_id_workspace_id_fk", + "tableFrom": "credential", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_account_id_account_id_fk": { + "name": "credential_account_id_account_id_fk", + "tableFrom": "credential", + "tableTo": "account", + "columnsFrom": ["account_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_env_owner_user_id_user_id_fk": { + "name": "credential_env_owner_user_id_user_id_fk", + "tableFrom": "credential", + "tableTo": "user", + "columnsFrom": ["env_owner_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_credential_group_enrollment_id_credential_group_enrollment_id_fk": { + "name": "credential_credential_group_enrollment_id_credential_group_enrollment_id_fk", + "tableFrom": "credential", + "tableTo": "credential_group_enrollment", + "columnsFrom": ["credential_group_enrollment_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_created_by_user_id_fk": { + "name": "credential_created_by_user_id_fk", + "tableFrom": "credential", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "credential_oauth_source_check": { + "name": "credential_oauth_source_check", + "value": "(type <> 'oauth') OR (account_id IS NOT NULL AND provider_id IS NOT NULL)" + }, + "credential_managed_oauth_source_check": { + "name": "credential_managed_oauth_source_check", + "value": "(type::text <> 'managed_oauth') OR (\n account_id IS NULL\n AND provider_id IS NOT NULL\n AND authorization_app_id IS NOT NULL\n AND provider_subject_id IS NOT NULL\n AND managed_oauth_status IS NOT NULL\n AND granted_scopes IS NOT NULL\n AND cardinality(granted_scopes) > 0\n AND encrypted_oauth_token_set IS NOT NULL\n AND granted_at IS NOT NULL\n )" + }, + "credential_managed_oauth_group_binding_check": { + "name": "credential_managed_oauth_group_binding_check", + "value": "(type::text <> 'managed_oauth') OR (\n credential_group_enrollment_id IS NOT NULL\n AND credential_group_option_id IS NOT NULL\n AND managed_oauth_scope_version IS NOT NULL\n AND managed_oauth_scope_version > 0\n )" + }, + "credential_workspace_env_source_check": { + "name": "credential_workspace_env_source_check", + "value": "(type <> 'env_workspace') OR (env_key IS NOT NULL AND env_owner_user_id IS NULL)" + }, + "credential_personal_env_source_check": { + "name": "credential_personal_env_source_check", + "value": "(type <> 'env_personal') OR (env_key IS NOT NULL AND env_owner_user_id IS NOT NULL)" + } + }, + "isRLSEnabled": false + }, + "public.credential_group": { + "name": "credential_group", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "public_id": { + "name": "public_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "options": { + "name": "options", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "encrypted_provider_configuration": { + "name": "encrypted_provider_configuration", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "credential_group_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_group_public_id_unique": { + "name": "credential_group_public_id_unique", + "columns": [ + { + "expression": "public_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_group_workspace_status_idx": { + "name": "credential_group_workspace_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_group_workspace_name_unique": { + "name": "credential_group_workspace_name_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"name\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_group_workspace_id_workspace_id_fk": { + "name": "credential_group_workspace_id_workspace_id_fk", + "tableFrom": "credential_group", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_group_created_by_user_id_fk": { + "name": "credential_group_created_by_user_id_fk", + "tableFrom": "credential_group", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credential_group_enrollment": { + "name": "credential_group_enrollment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "credential_group_id": { + "name": "credential_group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "credential_group_enrollment_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'invited'" + }, + "invitation_token_hash": { + "name": "invitation_token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "invitation_expires_at": { + "name": "invitation_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "invited_at": { + "name": "invited_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_delivery_error": { + "name": "last_delivery_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_group_enrollment_group_email_unique": { + "name": "credential_group_enrollment_group_email_unique", + "columns": [ + { + "expression": "credential_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_group_enrollment_invitation_token_hash_unique": { + "name": "credential_group_enrollment_invitation_token_hash_unique", + "columns": [ + { + "expression": "invitation_token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_group_enrollment_group_status_idx": { + "name": "credential_group_enrollment_group_status_idx", + "columns": [ + { + "expression": "credential_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_group_enrollment_group_invited_at_id_idx": { + "name": "credential_group_enrollment_group_invited_at_id_idx", + "columns": [ + { + "expression": "credential_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "invited_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_group_enrollment_credential_group_id_credential_group_id_fk": { + "name": "credential_group_enrollment_credential_group_id_credential_group_id_fk", + "tableFrom": "credential_group_enrollment", + "tableTo": "credential_group", + "columnsFrom": ["credential_group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_group_enrollment_created_by_user_id_fk": { + "name": "credential_group_enrollment_created_by_user_id_fk", + "tableFrom": "credential_group_enrollment", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "credential_group_enrollment_normalized_email_check": { + "name": "credential_group_enrollment_normalized_email_check", + "value": "\"credential_group_enrollment\".\"email\" = lower(btrim(\"credential_group_enrollment\".\"email\")) AND length(\"credential_group_enrollment\".\"email\") BETWEEN 3 AND 320" + }, + "credential_group_enrollment_invitation_token_hash_length_check": { + "name": "credential_group_enrollment_invitation_token_hash_length_check", + "value": "length(\"credential_group_enrollment\".\"invitation_token_hash\") = 64" + } + }, + "isRLSEnabled": false + }, + "public.credential_member": { + "name": "credential_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "credential_member_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "status": { + "name": "status", + "type": "credential_member_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_member_user_id_idx": { + "name": "credential_member_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_role_idx": { + "name": "credential_member_role_idx", + "columns": [ + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_status_idx": { + "name": "credential_member_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_unique": { + "name": "credential_member_unique", + "columns": [ + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_member_credential_id_credential_id_fk": { + "name": "credential_member_credential_id_credential_id_fk", + "tableFrom": "credential_member", + "tableTo": "credential", + "columnsFrom": ["credential_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_member_user_id_user_id_fk": { + "name": "credential_member_user_id_user_id_fk", + "tableFrom": "credential_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_member_invited_by_user_id_fk": { + "name": "credential_member_invited_by_user_id_fk", + "tableFrom": "credential_member", + "tableTo": "user", + "columnsFrom": ["invited_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.custom_block": { + "name": "custom_block", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "icon_url": { + "name": "icon_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "inputs": { + "name": "inputs", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "outputs": { + "name": "outputs", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "trace_child_runs": { + "name": "trace_child_runs", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "custom_block_organization_id_idx": { + "name": "custom_block_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_block_workflow_id_idx": { + "name": "custom_block_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_block_organization_type_unique": { + "name": "custom_block_organization_type_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "custom_block_organization_id_organization_id_fk": { + "name": "custom_block_organization_id_organization_id_fk", + "tableFrom": "custom_block", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "custom_block_workflow_id_workflow_id_fk": { + "name": "custom_block_workflow_id_workflow_id_fk", + "tableFrom": "custom_block", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "custom_block_created_by_user_id_fk": { + "name": "custom_block_created_by_user_id_fk", + "tableFrom": "custom_block", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.custom_tools": { + "name": "custom_tools", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schema": { + "name": "schema", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "custom_tools_workspace_id_idx": { + "name": "custom_tools_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_tools_workspace_title_unique": { + "name": "custom_tools_workspace_title_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "title", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "custom_tools_workspace_id_workspace_id_fk": { + "name": "custom_tools_workspace_id_workspace_id_fk", + "tableFrom": "custom_tools", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "custom_tools_user_id_user_id_fk": { + "name": "custom_tools_user_id_user_id_fk", + "tableFrom": "custom_tools", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.data_drain_runs": { + "name": "data_drain_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "drain_id": { + "name": "drain_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "data_drain_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "trigger": { + "name": "trigger", + "type": "data_drain_run_trigger", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "rows_exported": { + "name": "rows_exported", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "bytes_written": { + "name": "bytes_written", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cursor_before": { + "name": "cursor_before", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cursor_after": { + "name": "cursor_after", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "locators": { + "name": "locators", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + } + }, + "indexes": { + "data_drain_runs_drain_started_idx": { + "name": "data_drain_runs_drain_started_idx", + "columns": [ + { + "expression": "drain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "data_drain_runs_drain_id_data_drains_id_fk": { + "name": "data_drain_runs_drain_id_data_drains_id_fk", + "tableFrom": "data_drain_runs", + "tableTo": "data_drains", + "columnsFrom": ["drain_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.data_drains": { + "name": "data_drains", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "data_drain_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "destination_type": { + "name": "destination_type", + "type": "data_drain_destination", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "destination_config": { + "name": "destination_config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "destination_credentials": { + "name": "destination_credentials", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schedule_cadence": { + "name": "schedule_cadence", + "type": "data_drain_cadence", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "cursor": { + "name": "cursor", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_success_at": { + "name": "last_success_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "data_drains_org_idx": { + "name": "data_drains_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "data_drains_due_idx": { + "name": "data_drains_due_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "data_drains_org_name_unique": { + "name": "data_drains_org_name_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "data_drains_organization_id_organization_id_fk": { + "name": "data_drains_organization_id_organization_id_fk", + "tableFrom": "data_drains", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "data_drains_created_by_user_id_fk": { + "name": "data_drains_created_by_user_id_fk", + "tableFrom": "data_drains", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.docs_embeddings": { + "name": "docs_embeddings", + "schema": "", + "columns": { + "chunk_id": { + "name": "chunk_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chunk_text": { + "name": "chunk_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_document": { + "name": "source_document", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_link": { + "name": "source_link", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "header_text": { + "name": "header_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "header_level": { + "name": "header_level", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": true + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "chunk_text_tsv": { + "name": "chunk_text_tsv", + "type": "tsvector", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "to_tsvector('english', \"docs_embeddings\".\"chunk_text\")", + "type": "stored" + } + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "docs_emb_source_document_idx": { + "name": "docs_emb_source_document_idx", + "columns": [ + { + "expression": "source_document", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_header_level_idx": { + "name": "docs_emb_header_level_idx", + "columns": [ + { + "expression": "header_level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_source_header_idx": { + "name": "docs_emb_source_header_idx", + "columns": [ + { + "expression": "source_document", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "header_level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_model_idx": { + "name": "docs_emb_model_idx", + "columns": [ + { + "expression": "embedding_model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_created_at_idx": { + "name": "docs_emb_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_embedding_vector_hnsw_idx": { + "name": "docs_embedding_vector_hnsw_idx", + "columns": [ + { + "expression": "embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "docs_emb_metadata_gin_idx": { + "name": "docs_emb_metadata_gin_idx", + "columns": [ + { + "expression": "metadata", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "docs_emb_chunk_text_fts_idx": { + "name": "docs_emb_chunk_text_fts_idx", + "columns": [ + { + "expression": "chunk_text_tsv", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "docs_embedding_not_null_check": { + "name": "docs_embedding_not_null_check", + "value": "\"embedding\" IS NOT NULL" + }, + "docs_header_level_check": { + "name": "docs_header_level_check", + "value": "\"header_level\" >= 1 AND \"header_level\" <= 6" + } + }, + "isRLSEnabled": false + }, + "public.document": { + "name": "document", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_url": { + "name": "file_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "file_size": { + "name": "file_size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chunk_count": { + "name": "chunk_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "character_count": { + "name": "character_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "processing_status": { + "name": "processing_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "processing_attempts": { + "name": "processing_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "processing_queued_at": { + "name": "processing_queued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "processing_queue_token": { + "name": "processing_queue_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "processing_started_at": { + "name": "processing_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "processing_deferred_until": { + "name": "processing_deferred_until", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "processing_completed_at": { + "name": "processing_completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "processing_error": { + "name": "processing_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "user_excluded": { + "name": "user_excluded", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "tag1": { + "name": "tag1", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag2": { + "name": "tag2", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag3": { + "name": "tag3", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag4": { + "name": "tag4", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag5": { + "name": "tag5", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag6": { + "name": "tag6", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag7": { + "name": "tag7", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "number1": { + "name": "number1", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number2": { + "name": "number2", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number3": { + "name": "number3", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number4": { + "name": "number4", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number5": { + "name": "number5", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "date1": { + "name": "date1", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "date2": { + "name": "date2", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "boolean1": { + "name": "boolean1", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean2": { + "name": "boolean2", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean3": { + "name": "boolean3", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "connector_id": { + "name": "connector_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_url": { + "name": "source_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "secret_provenance_version": { + "name": "secret_provenance_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "doc_kb_id_idx": { + "name": "doc_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_filename_idx": { + "name": "doc_filename_idx", + "columns": [ + { + "expression": "filename", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_processing_status_idx": { + "name": "doc_processing_status_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "processing_status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_connector_external_id_idx": { + "name": "doc_connector_external_id_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"document\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_connector_id_idx": { + "name": "doc_connector_id_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_active_kb_token_count_idx": { + "name": "doc_active_kb_token_count_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "token_count", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"user_excluded\" = false AND \"document\".\"archived_at\" IS NULL AND \"document\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_storage_key_idx": { + "name": "doc_storage_key_idx", + "columns": [ + { + "expression": "storage_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"storage_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_archived_at_partial_idx": { + "name": "doc_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_deleted_at_partial_idx": { + "name": "doc_deleted_at_partial_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag1_idx": { + "name": "doc_tag1_idx", + "columns": [ + { + "expression": "tag1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag2_idx": { + "name": "doc_tag2_idx", + "columns": [ + { + "expression": "tag2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag3_idx": { + "name": "doc_tag3_idx", + "columns": [ + { + "expression": "tag3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag4_idx": { + "name": "doc_tag4_idx", + "columns": [ + { + "expression": "tag4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag5_idx": { + "name": "doc_tag5_idx", + "columns": [ + { + "expression": "tag5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag6_idx": { + "name": "doc_tag6_idx", + "columns": [ + { + "expression": "tag6", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag7_idx": { + "name": "doc_tag7_idx", + "columns": [ + { + "expression": "tag7", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number1_idx": { + "name": "doc_number1_idx", + "columns": [ + { + "expression": "number1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number2_idx": { + "name": "doc_number2_idx", + "columns": [ + { + "expression": "number2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number3_idx": { + "name": "doc_number3_idx", + "columns": [ + { + "expression": "number3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number4_idx": { + "name": "doc_number4_idx", + "columns": [ + { + "expression": "number4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number5_idx": { + "name": "doc_number5_idx", + "columns": [ + { + "expression": "number5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_date1_idx": { + "name": "doc_date1_idx", + "columns": [ + { + "expression": "date1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_date2_idx": { + "name": "doc_date2_idx", + "columns": [ + { + "expression": "date2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean1_idx": { + "name": "doc_boolean1_idx", + "columns": [ + { + "expression": "boolean1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean2_idx": { + "name": "doc_boolean2_idx", + "columns": [ + { + "expression": "boolean2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean3_idx": { + "name": "doc_boolean3_idx", + "columns": [ + { + "expression": "boolean3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "document_knowledge_base_id_knowledge_base_id_fk": { + "name": "document_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "document", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "document_connector_id_knowledge_connector_id_fk": { + "name": "document_connector_id_knowledge_connector_id_fk", + "tableFrom": "document", + "tableTo": "knowledge_connector", + "columnsFrom": ["connector_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "document_uploaded_by_user_id_fk": { + "name": "document_uploaded_by_user_id_fk", + "tableFrom": "document", + "tableTo": "user", + "columnsFrom": ["uploaded_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.document_secret_provenance": { + "name": "document_secret_provenance", + "schema": "", + "columns": { + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "source_hash": { + "name": "source_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entries": { + "name": "entries", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "document_secret_provenance_document_id_document_id_fk": { + "name": "document_secret_provenance_document_id_document_id_fk", + "tableFrom": "document_secret_provenance", + "tableTo": "document", + "columnsFrom": ["document_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "document_secret_provenance_status_check": { + "name": "document_secret_provenance_status_check", + "value": "\"document_secret_provenance\".\"status\" IN ('exact', 'unknown')" + } + }, + "isRLSEnabled": false + }, + "public.embedding": { + "name": "embedding", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chunk_index": { + "name": "chunk_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "chunk_hash": { + "name": "chunk_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret_provenance_version": { + "name": "secret_provenance_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "content_length": { + "name": "content_length", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": false + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "start_offset": { + "name": "start_offset", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "end_offset": { + "name": "end_offset", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "tag1": { + "name": "tag1", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag2": { + "name": "tag2", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag3": { + "name": "tag3", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag4": { + "name": "tag4", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag5": { + "name": "tag5", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag6": { + "name": "tag6", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag7": { + "name": "tag7", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "number1": { + "name": "number1", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number2": { + "name": "number2", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number3": { + "name": "number3", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number4": { + "name": "number4", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number5": { + "name": "number5", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "date1": { + "name": "date1", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "date2": { + "name": "date2", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "boolean1": { + "name": "boolean1", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean2": { + "name": "boolean2", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean3": { + "name": "boolean3", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "content_tsv": { + "name": "content_tsv", + "type": "tsvector", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "to_tsvector('english', \"embedding\".\"content\")", + "type": "stored" + } + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "emb_kb_id_idx": { + "name": "emb_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_id_idx": { + "name": "emb_doc_id_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_chunk_idx": { + "name": "emb_doc_chunk_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chunk_index", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_model_idx": { + "name": "emb_kb_model_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "embedding_model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_enabled_idx": { + "name": "emb_kb_enabled_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_enabled_idx": { + "name": "emb_doc_enabled_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "embedding_vector_hnsw_idx": { + "name": "embedding_vector_hnsw_idx", + "columns": [ + { + "expression": "embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "emb_tag1_idx": { + "name": "emb_tag1_idx", + "columns": [ + { + "expression": "tag1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag2_idx": { + "name": "emb_tag2_idx", + "columns": [ + { + "expression": "tag2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag3_idx": { + "name": "emb_tag3_idx", + "columns": [ + { + "expression": "tag3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag4_idx": { + "name": "emb_tag4_idx", + "columns": [ + { + "expression": "tag4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag5_idx": { + "name": "emb_tag5_idx", + "columns": [ + { + "expression": "tag5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag6_idx": { + "name": "emb_tag6_idx", + "columns": [ + { + "expression": "tag6", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag7_idx": { + "name": "emb_tag7_idx", + "columns": [ + { + "expression": "tag7", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number1_idx": { + "name": "emb_number1_idx", + "columns": [ + { + "expression": "number1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number2_idx": { + "name": "emb_number2_idx", + "columns": [ + { + "expression": "number2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number3_idx": { + "name": "emb_number3_idx", + "columns": [ + { + "expression": "number3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number4_idx": { + "name": "emb_number4_idx", + "columns": [ + { + "expression": "number4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number5_idx": { + "name": "emb_number5_idx", + "columns": [ + { + "expression": "number5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_date1_idx": { + "name": "emb_date1_idx", + "columns": [ + { + "expression": "date1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_date2_idx": { + "name": "emb_date2_idx", + "columns": [ + { + "expression": "date2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_boolean1_idx": { + "name": "emb_boolean1_idx", + "columns": [ + { + "expression": "boolean1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_boolean2_idx": { + "name": "emb_boolean2_idx", + "columns": [ + { + "expression": "boolean2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_boolean3_idx": { + "name": "emb_boolean3_idx", + "columns": [ + { + "expression": "boolean3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_content_fts_idx": { + "name": "emb_content_fts_idx", + "columns": [ + { + "expression": "content_tsv", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "embedding_knowledge_base_id_knowledge_base_id_fk": { + "name": "embedding_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "embedding", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "embedding_document_id_document_id_fk": { + "name": "embedding_document_id_document_id_fk", + "tableFrom": "embedding", + "tableTo": "document", + "columnsFrom": ["document_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "embedding_not_null_check": { + "name": "embedding_not_null_check", + "value": "\"embedding\" IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.embedding_secret_provenance": { + "name": "embedding_secret_provenance", + "schema": "", + "columns": { + "embedding_id": { + "name": "embedding_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entries": { + "name": "entries", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "embedding_secret_provenance_embedding_id_embedding_id_fk": { + "name": "embedding_secret_provenance_embedding_id_embedding_id_fk", + "tableFrom": "embedding_secret_provenance", + "tableTo": "embedding", + "columnsFrom": ["embedding_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "embedding_secret_provenance_status_check": { + "name": "embedding_secret_provenance_status_check", + "value": "\"embedding_secret_provenance\".\"status\" IN ('exact', 'unknown')" + } + }, + "isRLSEnabled": false + }, + "public.environment": { + "name": "environment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "environment_user_id_user_id_fk": { + "name": "environment_user_id_user_id_fk", + "tableFrom": "environment", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "environment_user_id_unique": { + "name": "environment_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.execution_large_value_dependencies": { + "name": "execution_large_value_dependencies", + "schema": "", + "columns": { + "parent_key": { + "name": "parent_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_key": { + "name": "child_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "execution_large_value_dependencies_workspace_parent_key_idx": { + "name": "execution_large_value_dependencies_workspace_parent_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_value_dependencies_workspace_child_key_idx": { + "name": "execution_large_value_dependencies_workspace_child_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "child_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "execution_large_value_dependencies_workspace_id_workspace_id_fk": { + "name": "execution_large_value_dependencies_workspace_id_workspace_id_fk", + "tableFrom": "execution_large_value_dependencies", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "execution_large_value_dependencies_parent_key_child_key_pk": { + "name": "execution_large_value_dependencies_parent_key_child_key_pk", + "columns": ["parent_key", "child_key"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.execution_large_value_references": { + "name": "execution_large_value_references", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "execution_large_value_reference_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "execution_large_value_references_workspace_execution_source_idx": { + "name": "execution_large_value_references_workspace_execution_source_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_value_references_workflow_id_idx": { + "name": "execution_large_value_references_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "execution_large_value_references_workspace_id_workspace_id_fk": { + "name": "execution_large_value_references_workspace_id_workspace_id_fk", + "tableFrom": "execution_large_value_references", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "execution_large_value_references_workflow_id_workflow_id_fk": { + "name": "execution_large_value_references_workflow_id_workflow_id_fk", + "tableFrom": "execution_large_value_references", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "execution_large_value_references_key_execution_id_source_pk": { + "name": "execution_large_value_references_key_execution_id_source_pk", + "columns": ["key", "execution_id", "source"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.execution_large_values": { + "name": "execution_large_values", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_execution_id": { + "name": "owner_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "execution_large_values_owner_execution_id_idx": { + "name": "execution_large_values_owner_execution_id_idx", + "columns": [ + { + "expression": "owner_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_values_cleanup_idx": { + "name": "execution_large_values_cleanup_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"execution_large_values\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_values_tombstone_cleanup_idx": { + "name": "execution_large_values_tombstone_cleanup_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"execution_large_values\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_values_workflow_id_idx": { + "name": "execution_large_values_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "execution_large_values_workspace_id_workspace_id_fk": { + "name": "execution_large_values_workspace_id_workspace_id_fk", + "tableFrom": "execution_large_values", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "execution_large_values_workflow_id_workflow_id_fk": { + "name": "execution_large_values_workflow_id_workflow_id_fk", + "tableFrom": "execution_large_values", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.folder": { + "name": "folder", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "folder_resource_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "folder_user_idx": { + "name": "folder_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "folder_workspace_resource_parent_idx": { + "name": "folder_workspace_resource_parent_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "folder_parent_sort_idx": { + "name": "folder_parent_sort_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "folder_deleted_at_idx": { + "name": "folder_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "folder_workspace_deleted_partial_idx": { + "name": "folder_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"folder\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "folder_workspace_resource_parent_name_active_unique": { + "name": "folder_workspace_resource_parent_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"parent_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"folder\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "folder_user_id_user_id_fk": { + "name": "folder_user_id_user_id_fk", + "tableFrom": "folder", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "folder_workspace_id_workspace_id_fk": { + "name": "folder_workspace_id_workspace_id_fk", + "tableFrom": "folder", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "folder_parent_id_folder_id_fk": { + "name": "folder_parent_id_folder_id_fk", + "tableFrom": "folder", + "tableTo": "folder", + "columnsFrom": ["parent_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.idempotency_key": { + "name": "idempotency_key", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "result": { + "name": "result", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idempotency_key_created_at_idx": { + "name": "idempotency_key_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation": { + "name": "invitation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "invitation_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'organization'" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "membership_intent": { + "name": "membership_intent", + "type": "invitation_membership_intent", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'internal'" + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "invitation_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invitation_email_idx": { + "name": "invitation_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_organization_id_idx": { + "name": "invitation_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_status_idx": { + "name": "invitation_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_pending_email_org_unique": { + "name": "invitation_pending_email_org_unique", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"invitation\".\"status\" = 'pending' AND \"invitation\".\"organization_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitation_inviter_id_user_id_fk": { + "name": "invitation_inviter_id_user_id_fk", + "tableFrom": "invitation", + "tableTo": "user", + "columnsFrom": ["inviter_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_organization_id_organization_id_fk": { + "name": "invitation_organization_id_organization_id_fk", + "tableFrom": "invitation", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "invitation_token_unique": { + "name": "invitation_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation_workspace_grant": { + "name": "invitation_workspace_grant", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "invitation_id": { + "name": "invitation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission": { + "name": "permission", + "type": "permission_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invitation_workspace_grant_unique": { + "name": "invitation_workspace_grant_unique", + "columns": [ + { + "expression": "invitation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_workspace_grant_workspace_id_idx": { + "name": "invitation_workspace_grant_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitation_workspace_grant_invitation_id_invitation_id_fk": { + "name": "invitation_workspace_grant_invitation_id_invitation_id_fk", + "tableFrom": "invitation_workspace_grant", + "tableTo": "invitation", + "columnsFrom": ["invitation_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_workspace_grant_workspace_id_workspace_id_fk": { + "name": "invitation_workspace_grant_workspace_id_workspace_id_fk", + "tableFrom": "invitation_workspace_grant", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.job_execution_logs": { + "name": "job_execution_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "schedule_id": { + "name": "schedule_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_duration_ms": { + "name": "total_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "execution_data": { + "name": "execution_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "cost": { + "name": "cost", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "job_execution_logs_schedule_id_idx": { + "name": "job_execution_logs_schedule_id_idx", + "columns": [ + { + "expression": "schedule_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_workspace_started_at_idx": { + "name": "job_execution_logs_workspace_started_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_workspace_ended_at_id_idx": { + "name": "job_execution_logs_workspace_ended_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"ended_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_execution_id_unique": { + "name": "job_execution_logs_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_trigger_idx": { + "name": "job_execution_logs_trigger_idx", + "columns": [ + { + "expression": "trigger", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "job_execution_logs_schedule_id_workflow_schedule_id_fk": { + "name": "job_execution_logs_schedule_id_workflow_schedule_id_fk", + "tableFrom": "job_execution_logs", + "tableTo": "workflow_schedule", + "columnsFrom": ["schedule_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "job_execution_logs_workspace_id_workspace_id_fk": { + "name": "job_execution_logs_workspace_id_workspace_id_fk", + "tableFrom": "job_execution_logs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_base": { + "name": "knowledge_base", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "embedding_dimension": { + "name": "embedding_dimension", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1536 + }, + "chunking_config": { + "name": "chunking_config", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{\"maxSize\": 1024, \"minSize\": 1, \"overlap\": 200}'" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "kb_user_id_idx": { + "name": "kb_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_workspace_id_idx": { + "name": "kb_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_user_workspace_idx": { + "name": "kb_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_folder_id_idx": { + "name": "kb_folder_id_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_deleted_at_idx": { + "name": "kb_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_workspace_deleted_partial_idx": { + "name": "kb_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_base\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_workspace_name_active_unique": { + "name": "kb_workspace_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"knowledge_base\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_base_user_id_user_id_fk": { + "name": "knowledge_base_user_id_user_id_fk", + "tableFrom": "knowledge_base", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "knowledge_base_workspace_id_workspace_id_fk": { + "name": "knowledge_base_workspace_id_workspace_id_fk", + "tableFrom": "knowledge_base", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "knowledge_base_folder_id_folder_id_fk": { + "name": "knowledge_base_folder_id_folder_id_fk", + "tableFrom": "knowledge_base", + "tableTo": "folder", + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_base_tag_definitions": { + "name": "knowledge_base_tag_definitions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tag_slot": { + "name": "tag_slot", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "field_type": { + "name": "field_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "kb_tag_definitions_kb_slot_idx": { + "name": "kb_tag_definitions_kb_slot_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tag_slot", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_tag_definitions_kb_display_name_idx": { + "name": "kb_tag_definitions_kb_display_name_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "display_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_tag_definitions_kb_id_idx": { + "name": "kb_tag_definitions_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_base_tag_definitions_knowledge_base_id_knowledge_base_id_fk": { + "name": "knowledge_base_tag_definitions_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "knowledge_base_tag_definitions", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_connector": { + "name": "knowledge_connector", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connector_type": { + "name": "connector_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_api_key": { + "name": "encrypted_api_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_config": { + "name": "source_config", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "sync_mode": { + "name": "sync_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'full'" + }, + "sync_interval_minutes": { + "name": "sync_interval_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1440 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "last_sync_at": { + "name": "last_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_sync_error": { + "name": "last_sync_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_sync_doc_count": { + "name": "last_sync_doc_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "next_sync_at": { + "name": "next_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "consecutive_failures": { + "name": "consecutive_failures", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "sync_lock_token": { + "name": "sync_lock_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sync_lock_lease_at": { + "name": "sync_lock_lease_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "kc_knowledge_base_id_idx": { + "name": "kc_knowledge_base_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_status_next_sync_idx": { + "name": "kc_status_next_sync_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_sync_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_archived_at_partial_idx": { + "name": "kc_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_connector\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_deleted_at_partial_idx": { + "name": "kc_deleted_at_partial_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_connector\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_connector_knowledge_base_id_knowledge_base_id_fk": { + "name": "knowledge_connector_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "knowledge_connector", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_connector_sync_log": { + "name": "knowledge_connector_sync_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connector_id": { + "name": "connector_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "docs_added": { + "name": "docs_added", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_updated": { + "name": "docs_updated", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_deleted": { + "name": "docs_deleted", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_unchanged": { + "name": "docs_unchanged", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_skipped": { + "name": "docs_skipped", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_failed": { + "name": "docs_failed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "kcsl_connector_started_at_idx": { + "name": "kcsl_connector_started_at_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"started_at\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kcsl_started_at_partial_idx": { + "name": "kcsl_started_at_partial_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_connector_sync_log\".\"status\" = 'started'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_connector_sync_log_connector_id_knowledge_connector_id_fk": { + "name": "knowledge_connector_sync_log_connector_id_knowledge_connector_id_fk", + "tableFrom": "knowledge_connector_sync_log", + "tableTo": "knowledge_connector", + "columnsFrom": ["connector_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_server_oauth": { + "name": "mcp_server_oauth", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "mcp_server_id": { + "name": "mcp_server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_information": { + "name": "client_information", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tokens": { + "name": "tokens", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "code_verifier": { + "name": "code_verifier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state_created_at": { + "name": "state_created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_refreshed_at": { + "name": "last_refreshed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_server_oauth_server_unique": { + "name": "mcp_server_oauth_server_unique", + "columns": [ + { + "expression": "mcp_server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_server_oauth_state_idx": { + "name": "mcp_server_oauth_state_idx", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_server_oauth_mcp_server_id_mcp_servers_id_fk": { + "name": "mcp_server_oauth_mcp_server_id_mcp_servers_id_fk", + "tableFrom": "mcp_server_oauth", + "tableTo": "mcp_servers", + "columnsFrom": ["mcp_server_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_server_oauth_user_id_user_id_fk": { + "name": "mcp_server_oauth_user_id_user_id_fk", + "tableFrom": "mcp_server_oauth", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "mcp_server_oauth_workspace_id_workspace_id_fk": { + "name": "mcp_server_oauth_workspace_id_workspace_id_fk", + "tableFrom": "mcp_server_oauth", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_servers": { + "name": "mcp_servers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "transport": { + "name": "transport", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'headers'" + }, + "oauth_client_id": { + "name": "oauth_client_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oauth_client_secret": { + "name": "oauth_client_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "headers": { + "name": "headers", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "timeout": { + "name": "timeout", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 30000 + }, + "retries": { + "name": "retries", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 3 + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_connected": { + "name": "last_connected", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "connection_status": { + "name": "connection_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'disconnected'" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status_config": { + "name": "status_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "tool_count": { + "name": "tool_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_tools_refresh": { + "name": "last_tools_refresh", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_requests": { + "name": "total_requests", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_used": { + "name": "last_used", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_servers_workspace_enabled_idx": { + "name": "mcp_servers_workspace_enabled_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_servers_workspace_deleted_partial_idx": { + "name": "mcp_servers_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"mcp_servers\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_servers_workspace_id_workspace_id_fk": { + "name": "mcp_servers_workspace_id_workspace_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_servers_created_by_user_id_fk": { + "name": "mcp_servers_created_by_user_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.member": { + "name": "member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "member_user_id_unique": { + "name": "member_user_id_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "member_organization_id_idx": { + "name": "member_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "member_user_id_user_id_fk": { + "name": "member_user_id_user_id_fk", + "tableFrom": "member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "member_organization_id_organization_id_fk": { + "name": "member_organization_id_organization_id_fk", + "tableFrom": "member", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memory": { + "name": "memory", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "secret_provenance_version": { + "name": "secret_provenance_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "memory_key_idx": { + "name": "memory_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workspace_idx": { + "name": "memory_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workspace_key_idx": { + "name": "memory_workspace_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workspace_deleted_partial_idx": { + "name": "memory_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"memory\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "memory_workspace_id_workspace_id_fk": { + "name": "memory_workspace_id_workspace_id_fk", + "tableFrom": "memory", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memory_secret_provenance": { + "name": "memory_secret_provenance", + "schema": "", + "columns": { + "memory_id": { + "name": "memory_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entries": { + "name": "entries", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "memory_secret_provenance_memory_id_memory_id_fk": { + "name": "memory_secret_provenance_memory_id_memory_id_fk", + "tableFrom": "memory_secret_provenance", + "tableTo": "memory", + "columnsFrom": ["memory_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "memory_secret_provenance_status_check": { + "name": "memory_secret_provenance_status_check", + "value": "\"memory_secret_provenance\".\"status\" IN ('exact', 'unknown')" + } + }, + "isRLSEnabled": false + }, + "public.mothership_inbox_allowed_sender": { + "name": "mothership_inbox_allowed_sender", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "added_by": { + "name": "added_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "inbox_sender_ws_email_idx": { + "name": "inbox_sender_ws_email_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mothership_inbox_allowed_sender_workspace_id_workspace_id_fk": { + "name": "mothership_inbox_allowed_sender_workspace_id_workspace_id_fk", + "tableFrom": "mothership_inbox_allowed_sender", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mothership_inbox_allowed_sender_added_by_user_id_fk": { + "name": "mothership_inbox_allowed_sender_added_by_user_id_fk", + "tableFrom": "mothership_inbox_allowed_sender", + "tableTo": "user", + "columnsFrom": ["added_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_inbox_task": { + "name": "mothership_inbox_task", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "from_email": { + "name": "from_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "from_name": { + "name": "from_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body_preview": { + "name": "body_preview", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "body_text": { + "name": "body_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "body_html": { + "name": "body_html", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_message_id": { + "name": "email_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "in_reply_to": { + "name": "in_reply_to", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "response_message_id": { + "name": "response_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agentmail_message_id": { + "name": "agentmail_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'received'" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "trigger_job_id": { + "name": "trigger_job_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "result_summary": { + "name": "result_summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rejection_reason": { + "name": "rejection_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "has_attachments": { + "name": "has_attachments", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "cc_recipients": { + "name": "cc_recipients", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "processing_started_at": { + "name": "processing_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "inbox_task_ws_created_at_idx": { + "name": "inbox_task_ws_created_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inbox_task_ws_status_idx": { + "name": "inbox_task_ws_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inbox_task_response_msg_id_idx": { + "name": "inbox_task_response_msg_id_idx", + "columns": [ + { + "expression": "response_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inbox_task_email_msg_id_idx": { + "name": "inbox_task_email_msg_id_idx", + "columns": [ + { + "expression": "email_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mothership_inbox_task_workspace_id_workspace_id_fk": { + "name": "mothership_inbox_task_workspace_id_workspace_id_fk", + "tableFrom": "mothership_inbox_task", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mothership_inbox_task_chat_id_copilot_chats_id_fk": { + "name": "mothership_inbox_task_chat_id_copilot_chats_id_fk", + "tableFrom": "mothership_inbox_task", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_inbox_webhook": { + "name": "mothership_inbox_webhook", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "webhook_id": { + "name": "webhook_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "mothership_inbox_webhook_workspace_id_workspace_id_fk": { + "name": "mothership_inbox_webhook_workspace_id_workspace_id_fk", + "tableFrom": "mothership_inbox_webhook", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "mothership_inbox_webhook_workspace_id_unique": { + "name": "mothership_inbox_webhook_workspace_id_unique", + "nullsNotDistinct": false, + "columns": ["workspace_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_settings": { + "name": "mothership_settings", + "schema": "", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "mcp_tool_refs": { + "name": "mcp_tool_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "custom_tool_refs": { + "name": "custom_tool_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "skill_refs": { + "name": "skill_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "mothership_settings_workspace_id_workspace_id_fk": { + "name": "mothership_settings_workspace_id_workspace_id_fk", + "tableFrom": "mothership_settings", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization": { + "name": "organization", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "session_policy_settings": { + "name": "session_policy_settings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "security_policy_version": { + "name": "security_policy_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "whitelabel_settings": { + "name": "whitelabel_settings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "data_retention_settings": { + "name": "data_retention_settings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "org_usage_limit": { + "name": "org_usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "limit_notifications": { + "name": "limit_notifications", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "departed_member_usage": { + "name": "departed_member_usage", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "credit_balance": { + "name": "credit_balance", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_byok_keys": { + "name": "organization_byok_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_api_key": { + "name": "encrypted_api_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "organization_byok_organization_provider_idx": { + "name": "organization_byok_organization_provider_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "organization_byok_keys_organization_id_organization_id_fk": { + "name": "organization_byok_keys_organization_id_organization_id_fk", + "tableFrom": "organization_byok_keys", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_byok_keys_created_by_user_id_fk": { + "name": "organization_byok_keys_created_by_user_id_fk", + "tableFrom": "organization_byok_keys", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_member_usage_limit": { + "name": "organization_member_usage_limit", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "usage_limit": { + "name": "usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "set_by": { + "name": "set_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "org_member_usage_limit_org_user_unique": { + "name": "org_member_usage_limit_org_user_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "org_member_usage_limit_organization_id_idx": { + "name": "org_member_usage_limit_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "organization_member_usage_limit_organization_id_organization_id_fk": { + "name": "organization_member_usage_limit_organization_id_organization_id_fk", + "tableFrom": "organization_member_usage_limit", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_member_usage_limit_user_id_user_id_fk": { + "name": "organization_member_usage_limit_user_id_user_id_fk", + "tableFrom": "organization_member_usage_limit", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_member_usage_limit_set_by_user_id_fk": { + "name": "organization_member_usage_limit_set_by_user_id_fk", + "tableFrom": "organization_member_usage_limit", + "tableTo": "user", + "columnsFrom": ["set_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.outbox_event": { + "name": "outbox_event", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "max_attempts": { + "name": "max_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10 + }, + "available_at": { + "name": "available_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "locked_at": { + "name": "locked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "outbox_event_status_available_idx": { + "name": "outbox_event_status_available_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "available_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "outbox_event_locked_at_idx": { + "name": "outbox_event_locked_at_idx", + "columns": [ + { + "expression": "locked_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "outbox_event_type_created_idx": { + "name": "outbox_event_type_created_idx", + "columns": [ + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.paused_executions": { + "name": "paused_executions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_snapshot": { + "name": "execution_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "pause_points": { + "name": "pause_points", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "total_pause_count": { + "name": "total_pause_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "resumed_count": { + "name": "resumed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "automatic_resume_retry_count": { + "name": "automatic_resume_retry_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'paused'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "paused_at": { + "name": "paused_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "next_resume_at": { + "name": "next_resume_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "paused_executions_workflow_id_idx": { + "name": "paused_executions_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paused_executions_status_idx": { + "name": "paused_executions_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paused_executions_execution_id_unique": { + "name": "paused_executions_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paused_executions_next_resume_at_idx": { + "name": "paused_executions_next_resume_at_idx", + "columns": [ + { + "expression": "next_resume_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "status = 'paused' AND next_resume_at IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "paused_executions_workflow_id_workflow_id_fk": { + "name": "paused_executions_workflow_id_workflow_id_fk", + "tableFrom": "paused_executions", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pending_credential_draft": { + "name": "pending_credential_draft", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pending_draft_user_provider_ws": { + "name": "pending_draft_user_provider_ws", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pending_credential_draft_user_id_user_id_fk": { + "name": "pending_credential_draft_user_id_user_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pending_credential_draft_workspace_id_workspace_id_fk": { + "name": "pending_credential_draft_workspace_id_workspace_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pending_credential_draft_credential_id_credential_id_fk": { + "name": "pending_credential_draft_credential_id_credential_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "credential", + "columnsFrom": ["credential_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission_group": { + "name": "permission_group", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "permission_group_created_by_idx": { + "name": "permission_group_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_organization_name_unique": { + "name": "permission_group_organization_name_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_organization_default_unique": { + "name": "permission_group_organization_default_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "is_default = true", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_group_organization_id_organization_id_fk": { + "name": "permission_group_organization_id_organization_id_fk", + "tableFrom": "permission_group", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_created_by_user_id_fk": { + "name": "permission_group_created_by_user_id_fk", + "tableFrom": "permission_group", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission_group_member": { + "name": "permission_group_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "permission_group_id": { + "name": "permission_group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "assigned_by": { + "name": "assigned_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assigned_at": { + "name": "assigned_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permission_group_member_group_id_idx": { + "name": "permission_group_member_group_id_idx", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_member_group_user_unique": { + "name": "permission_group_member_group_user_unique", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_member_organization_user_idx": { + "name": "permission_group_member_organization_user_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_group_member_permission_group_id_permission_group_id_fk": { + "name": "permission_group_member_permission_group_id_permission_group_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "permission_group", + "columnsFrom": ["permission_group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_member_organization_id_organization_id_fk": { + "name": "permission_group_member_organization_id_organization_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_member_user_id_user_id_fk": { + "name": "permission_group_member_user_id_user_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_member_assigned_by_user_id_fk": { + "name": "permission_group_member_assigned_by_user_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "user", + "columnsFrom": ["assigned_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission_group_workspace": { + "name": "permission_group_workspace", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "permission_group_id": { + "name": "permission_group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permission_group_workspace_workspace_id_idx": { + "name": "permission_group_workspace_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_workspace_group_workspace_unique": { + "name": "permission_group_workspace_group_workspace_unique", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_group_workspace_permission_group_id_permission_group_id_fk": { + "name": "permission_group_workspace_permission_group_id_permission_group_id_fk", + "tableFrom": "permission_group_workspace", + "tableTo": "permission_group", + "columnsFrom": ["permission_group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_workspace_workspace_id_workspace_id_fk": { + "name": "permission_group_workspace_workspace_id_workspace_id_fk", + "tableFrom": "permission_group_workspace", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_workspace_organization_id_organization_id_fk": { + "name": "permission_group_workspace_organization_id_organization_id_fk", + "tableFrom": "permission_group_workspace", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permissions": { + "name": "permissions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission_type": { + "name": "permission_type", + "type": "permission_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permissions_user_id_idx": { + "name": "permissions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_entity_idx": { + "name": "permissions_entity_idx", + "columns": [ + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_type_idx": { + "name": "permissions_user_entity_type_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_permission_idx": { + "name": "permissions_user_entity_permission_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "permission_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_unique_constraint": { + "name": "permissions_unique_constraint", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permissions_user_id_user_id_fk": { + "name": "permissions_user_id_user_id_fk", + "tableFrom": "permissions", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pinned_item": { + "name": "pinned_item", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pinned_at": { + "name": "pinned_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pinned_item_user_workspace_idx": { + "name": "pinned_item_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pinned_item_resource_idx": { + "name": "pinned_item_resource_idx", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pinned_item_user_resource_unique": { + "name": "pinned_item_user_resource_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pinned_item_user_id_user_id_fk": { + "name": "pinned_item_user_id_user_id_fk", + "tableFrom": "pinned_item", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pinned_item_workspace_id_workspace_id_fk": { + "name": "pinned_item_workspace_id_workspace_id_fk", + "tableFrom": "pinned_item", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.public_share": { + "name": "public_share", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowed_emails": { + "name": "allowed_emails", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "public_share_token_unique": { + "name": "public_share_token_unique", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "public_share_resource_unique": { + "name": "public_share_resource_unique", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "public_share_resource_id_idx": { + "name": "public_share_resource_id_idx", + "columns": [ + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "public_share_workspace_id_idx": { + "name": "public_share_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "public_share_workspace_id_workspace_id_fk": { + "name": "public_share_workspace_id_workspace_id_fk", + "tableFrom": "public_share", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "public_share_created_by_user_id_fk": { + "name": "public_share_created_by_user_id_fk", + "tableFrom": "public_share", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rate_limit_bucket": { + "name": "rate_limit_bucket", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tokens": { + "name": "tokens", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "last_refill_at": { + "name": "last_refill_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.resume_queue": { + "name": "resume_queue", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "paused_execution_id": { + "name": "paused_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_execution_id": { + "name": "parent_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "new_execution_id": { + "name": "new_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "context_id": { + "name": "context_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resume_input": { + "name": "resume_input", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "queued_at": { + "name": "queued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "failure_reason": { + "name": "failure_reason", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "resume_queue_parent_status_idx": { + "name": "resume_queue_parent_status_idx", + "columns": [ + { + "expression": "parent_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "resume_queue_new_execution_idx": { + "name": "resume_queue_new_execution_idx", + "columns": [ + { + "expression": "new_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "resume_queue_paused_execution_id_paused_executions_id_fk": { + "name": "resume_queue_paused_execution_id_paused_executions_id_fk", + "tableFrom": "resume_queue", + "tableTo": "paused_executions", + "columnsFrom": ["paused_execution_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sandbox_image": { + "name": "sandbox_image", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "spec_hash": { + "name": "spec_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "spec": { + "name": "spec", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "sandbox_image_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "image_ref": { + "name": "image_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_image_id": { + "name": "provider_image_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "build_id": { + "name": "build_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "materialization_generation": { + "name": "materialization_generation", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_detail": { + "name": "error_detail", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sandbox_image_provider_spec_unique": { + "name": "sandbox_image_provider_spec_unique", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "spec_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sandbox_image_status_idx": { + "name": "sandbox_image_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sandbox_image_last_used_idx": { + "name": "sandbox_image_last_used_idx", + "columns": [ + { + "expression": "last_used_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.secret_usage": { + "name": "secret_usage", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret_name": { + "name": "secret_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret_scope": { + "name": "secret_scope", + "type": "secret_usage_scope", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "secret_owner_user_id": { + "name": "secret_owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "source": { + "name": "source", + "type": "secret_usage_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "usage_date": { + "name": "usage_date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "use_count": { + "name": "use_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "last_execution_id": { + "name": "last_execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_trigger": { + "name": "last_trigger", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "secret_usage_bucket_unique": { + "name": "secret_usage_bucket_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "secret_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "secret_scope", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "secret_owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "actor_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "usage_date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "secret_usage_secret_recent_idx": { + "name": "secret_usage_secret_recent_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "secret_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "secret_scope", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "secret_owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_used_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "secret_usage_workspace_id_workspace_id_fk": { + "name": "secret_usage_workspace_id_workspace_id_fk", + "tableFrom": "secret_usage", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "impersonated_by": { + "name": "impersonated_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "session_user_id_idx": { + "name": "session_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_active_organization_id_organization_id_fk": { + "name": "session_active_organization_id_organization_id_fk", + "tableFrom": "session", + "tableTo": "organization", + "columnsFrom": ["active_organization_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.settings": { + "name": "settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "theme": { + "name": "theme", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'system'" + }, + "auto_connect": { + "name": "auto_connect", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "telemetry_enabled": { + "name": "telemetry_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "email_preferences": { + "name": "email_preferences", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "billing_usage_notifications_enabled": { + "name": "billing_usage_notifications_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "show_training_controls": { + "name": "show_training_controls", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "super_user_mode_enabled": { + "name": "super_user_mode_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "mothership_environment": { + "name": "mothership_environment", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'default'" + }, + "error_notifications_enabled": { + "name": "error_notifications_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "snap_to_grid_size": { + "name": "snap_to_grid_size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "show_action_bar": { + "name": "show_action_bar", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "auto_focus_on_click": { + "name": "auto_focus_on_click", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "copilot_enabled_models": { + "name": "copilot_enabled_models", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "copilot_auto_allowed_tools": { + "name": "copilot_auto_allowed_tools", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "last_active_workspace_id": { + "name": "last_active_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "settings_user_id_user_id_fk": { + "name": "settings_user_id_user_id_fk", + "tableFrom": "settings", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "settings_user_id_unique": { + "name": "settings_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sim_trigger_state": { + "name": "sim_trigger_state", + "schema": "", + "columns": { + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_key": { + "name": "scope_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "last_fired_at": { + "name": "last_fired_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "sim_trigger_state_workflow_id_workflow_id_fk": { + "name": "sim_trigger_state_workflow_id_workflow_id_fk", + "tableFrom": "sim_trigger_state", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "sim_trigger_state_workflow_id_block_id_scope_key_pk": { + "name": "sim_trigger_state_workflow_id_block_id_scope_key_pk", + "columns": ["workflow_id", "block_id", "scope_key"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skill": { + "name": "skill", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "skill_workspace_name_unique": { + "name": "skill_workspace_name_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skill_workspace_id_workspace_id_fk": { + "name": "skill_workspace_id_workspace_id_fk", + "tableFrom": "skill", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "skill_user_id_user_id_fk": { + "name": "skill_user_id_user_id_fk", + "tableFrom": "skill", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skill_member": { + "name": "skill_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "skill_id": { + "name": "skill_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "skill_member_user_id_idx": { + "name": "skill_member_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "skill_member_unique": { + "name": "skill_member_unique", + "columns": [ + { + "expression": "skill_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skill_member_skill_id_skill_id_fk": { + "name": "skill_member_skill_id_skill_id_fk", + "tableFrom": "skill_member", + "tableTo": "skill", + "columnsFrom": ["skill_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "skill_member_user_id_user_id_fk": { + "name": "skill_member_user_id_user_id_fk", + "tableFrom": "skill_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "skill_member_invited_by_user_id_fk": { + "name": "skill_member_invited_by_user_id_fk", + "tableFrom": "skill_member", + "tableTo": "user", + "columnsFrom": ["invited_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sso_domain": { + "name": "sso_domain", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "verification_token": { + "name": "verification_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "verified_at": { + "name": "verified_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sso_domain_organization_id_idx": { + "name": "sso_domain_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_domain_domain_idx": { + "name": "sso_domain_domain_idx", + "columns": [ + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_domain_org_domain_unique": { + "name": "sso_domain_org_domain_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_domain_verified_unique": { + "name": "sso_domain_verified_unique", + "columns": [ + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "status = 'verified'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sso_domain_organization_id_organization_id_fk": { + "name": "sso_domain_organization_id_organization_id_fk", + "tableFrom": "sso_domain", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sso_domain_created_by_user_id_fk": { + "name": "sso_domain_created_by_user_id_fk", + "tableFrom": "sso_domain", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sso_provider": { + "name": "sso_provider", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "oidc_config": { + "name": "oidc_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "saml_config": { + "name": "saml_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "domain_verified": { + "name": "domain_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + } + }, + "indexes": { + "sso_provider_provider_id_unique": { + "name": "sso_provider_provider_id_unique", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_domain_idx": { + "name": "sso_provider_domain_idx", + "columns": [ + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_user_id_idx": { + "name": "sso_provider_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_organization_id_idx": { + "name": "sso_provider_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sso_provider_user_id_user_id_fk": { + "name": "sso_provider_user_id_user_id_fk", + "tableFrom": "sso_provider", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sso_provider_organization_id_organization_id_fk": { + "name": "sso_provider_organization_id_organization_id_fk", + "tableFrom": "sso_provider", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.subscription": { + "name": "subscription", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "period_start": { + "name": "period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "period_end": { + "name": "period_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancel_at_period_end": { + "name": "cancel_at_period_end", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "cancel_at": { + "name": "cancel_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "canceled_at": { + "name": "canceled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "seats": { + "name": "seats", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "trial_start": { + "name": "trial_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trial_end": { + "name": "trial_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "billing_interval": { + "name": "billing_interval", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_schedule_id": { + "name": "stripe_schedule_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "last_closed_period_start": { + "name": "last_closed_period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "subscription_reference_status_idx": { + "name": "subscription_reference_status_idx", + "columns": [ + { + "expression": "reference_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "check_enterprise_metadata": { + "name": "check_enterprise_metadata", + "value": "plan != 'enterprise' OR metadata IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.table_jobs": { + "name": "table_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "rows_processed": { + "name": "rows_processed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "table_jobs_one_active_per_table": { + "name": "table_jobs_one_active_per_table", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"table_jobs\".\"status\" = 'running' AND \"table_jobs\".\"type\" <> 'export'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_jobs_watchdog_idx": { + "name": "table_jobs_watchdog_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_jobs_table_started_idx": { + "name": "table_jobs_table_started_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_jobs_table_id_user_table_definitions_id_fk": { + "name": "table_jobs_table_id_user_table_definitions_id_fk", + "tableFrom": "table_jobs", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_jobs_workspace_id_workspace_id_fk": { + "name": "table_jobs_workspace_id_workspace_id_fk", + "tableFrom": "table_jobs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.table_row_executions": { + "name": "table_row_executions", + "schema": "", + "columns": { + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "job_id": { + "name": "job_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "running_block_ids": { + "name": "running_block_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "block_errors": { + "name": "block_errors", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "enrichment_details": { + "name": "enrichment_details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "table_row_executions_table_status_idx": { + "name": "table_row_executions_table_status_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"table_row_executions\".\"status\" IN ('queued', 'running', 'pending')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_row_executions_execution_id_idx": { + "name": "table_row_executions_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"table_row_executions\".\"execution_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_row_executions_table_group_idx": { + "name": "table_row_executions_table_group_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_row_executions_table_id_user_table_definitions_id_fk": { + "name": "table_row_executions_table_id_user_table_definitions_id_fk", + "tableFrom": "table_row_executions", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_row_executions_row_id_user_table_rows_id_fk": { + "name": "table_row_executions_row_id_user_table_rows_id_fk", + "tableFrom": "table_row_executions", + "tableTo": "user_table_rows", + "columnsFrom": ["row_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "table_row_executions_row_id_group_id_pk": { + "name": "table_row_executions_row_id_group_id_pk", + "columns": ["row_id", "group_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.table_run_dispatches": { + "name": "table_run_dispatches", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "cursor": { + "name": "cursor", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "limit": { + "name": "limit", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "processed_count": { + "name": "processed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_manual_run": { + "name": "is_manual_run", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "triggered_by_user_id": { + "name": "triggered_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "requested_at": { + "name": "requested_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "heartbeat_at": { + "name": "heartbeat_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "table_run_dispatches_active_idx": { + "name": "table_run_dispatches_active_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_run_dispatches_watchdog_idx": { + "name": "table_run_dispatches_watchdog_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "requested_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_run_dispatches_table_id_user_table_definitions_id_fk": { + "name": "table_run_dispatches_table_id_user_table_definitions_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_run_dispatches_workspace_id_workspace_id_fk": { + "name": "table_run_dispatches_workspace_id_workspace_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_run_dispatches_triggered_by_user_id_user_id_fk": { + "name": "table_run_dispatches_triggered_by_user_id_user_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "user", + "columnsFrom": ["triggered_by_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.table_views": { + "name": "table_views", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "table_views_table_created_idx": { + "name": "table_views_table_created_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_views_workspace_created_idx": { + "name": "table_views_workspace_created_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_views_table_default_unique": { + "name": "table_views_table_default_unique", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "is_default = true", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_views_table_id_user_table_definitions_id_fk": { + "name": "table_views_table_id_user_table_definitions_id_fk", + "tableFrom": "table_views", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_views_workspace_id_workspace_id_fk": { + "name": "table_views_workspace_id_workspace_id_fk", + "tableFrom": "table_views", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_views_created_by_user_id_fk": { + "name": "table_views_created_by_user_id_fk", + "tableFrom": "table_views", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.upload_session": { + "name": "upload_session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "purpose": { + "name": "purpose", + "type": "upload_session_purpose", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "method": { + "name": "method", + "type": "upload_session_method", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "storage_context": { + "name": "storage_context", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "final_key": { + "name": "final_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_provider": { + "name": "storage_provider", + "type": "upload_session_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "provider_upload_id": { + "name": "provider_upload_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_object_version": { + "name": "provider_object_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "file_name": { + "name": "file_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_size": { + "name": "file_size", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "part_size": { + "name": "part_size", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "part_count": { + "name": "part_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "upload_session_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'uploading'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "processing_lease_id": { + "name": "processing_lease_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "processing_lease_expires_at": { + "name": "processing_lease_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_file_id": { + "name": "completed_file_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "upload_session_token_hash_unique": { + "name": "upload_session_token_hash_unique", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "upload_session_final_key_unique": { + "name": "upload_session_final_key_unique", + "columns": [ + { + "expression": "final_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "upload_session_status_expires_at_idx": { + "name": "upload_session_status_expires_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.usage_log": { + "name": "usage_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "usage_log_category", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "usage_log_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "cost": { + "name": "cost", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "event_key": { + "name": "event_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "billing_entity_type": { + "name": "billing_entity_type", + "type": "billing_entity_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "billing_entity_id": { + "name": "billing_entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "billing_period_start": { + "name": "billing_period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "billing_period_end": { + "name": "billing_period_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "usage_log_user_created_at_idx": { + "name": "usage_log_user_created_at_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_source_idx": { + "name": "usage_log_source_idx", + "columns": [ + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_workspace_id_idx": { + "name": "usage_log_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_workflow_id_idx": { + "name": "usage_log_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_event_key_unique": { + "name": "usage_log_event_key_unique", + "columns": [ + { + "expression": "event_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"usage_log\".\"event_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_billing_entity_period_idx": { + "name": "usage_log_billing_entity_period_idx", + "columns": [ + { + "expression": "billing_entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_period_start", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_period_end", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_log\".\"billing_entity_type\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_billing_period_cost_idx": { + "name": "usage_log_billing_period_cost_idx", + "columns": [ + { + "expression": "billing_entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_period_start", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_period_end", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_log\".\"billing_entity_type\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_billing_entity_created_at_cost_idx": { + "name": "usage_log_billing_entity_created_at_cost_idx", + "columns": [ + { + "expression": "billing_entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_log\".\"billing_entity_type\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_workspace_created_at_idx": { + "name": "usage_log_workspace_created_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_execution_id_idx": { + "name": "usage_log_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "usage_log_user_id_user_id_fk": { + "name": "usage_log_user_id_user_id_fk", + "tableFrom": "usage_log", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "usage_log_workspace_id_workspace_id_fk": { + "name": "usage_log_workspace_id_workspace_id_fk", + "tableFrom": "usage_log", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "usage_log_workflow_id_workflow_id_fk": { + "name": "usage_log_workflow_id_workflow_id_fk", + "tableFrom": "usage_log", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "usage_log_billing_scope_all_or_none": { + "name": "usage_log_billing_scope_all_or_none", + "value": "(\n (\"usage_log\".\"billing_entity_type\" IS NULL AND \"usage_log\".\"billing_entity_id\" IS NULL AND \"usage_log\".\"billing_period_start\" IS NULL AND \"usage_log\".\"billing_period_end\" IS NULL)\n OR\n (\"usage_log\".\"billing_entity_type\" IS NOT NULL AND \"usage_log\".\"billing_entity_id\" IS NOT NULL AND \"usage_log\".\"billing_period_start\" IS NOT NULL AND \"usage_log\".\"billing_period_end\" IS NOT NULL AND \"usage_log\".\"billing_period_start\" < \"usage_log\".\"billing_period_end\")\n )" + } + }, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "normalized_email": { + "name": "normalized_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'user'" + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + }, + "user_normalized_email_unique": { + "name": "user_normalized_email_unique", + "nullsNotDistinct": false, + "columns": ["normalized_email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_stats": { + "name": "user_stats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "total_manual_executions": { + "name": "total_manual_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_api_calls": { + "name": "total_api_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_webhook_triggers": { + "name": "total_webhook_triggers", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_scheduled_executions": { + "name": "total_scheduled_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_chat_executions": { + "name": "total_chat_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_mcp_executions": { + "name": "total_mcp_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_tokens_used": { + "name": "total_tokens_used", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_cost": { + "name": "total_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_usage_limit": { + "name": "current_usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'5'" + }, + "usage_limit_updated_at": { + "name": "usage_limit_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "current_period_cost": { + "name": "current_period_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "last_period_cost": { + "name": "last_period_cost", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "billed_overage_this_period": { + "name": "billed_overage_this_period", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "pro_period_cost_snapshot": { + "name": "pro_period_cost_snapshot", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "pro_period_cost_snapshot_at": { + "name": "pro_period_cost_snapshot_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "credit_balance": { + "name": "credit_balance", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "total_copilot_cost": { + "name": "total_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_period_copilot_cost": { + "name": "current_period_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "last_period_copilot_cost": { + "name": "last_period_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "total_copilot_tokens": { + "name": "total_copilot_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_copilot_calls": { + "name": "total_copilot_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_mcp_copilot_calls": { + "name": "total_mcp_copilot_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_mcp_copilot_cost": { + "name": "total_mcp_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_period_mcp_copilot_cost": { + "name": "current_period_mcp_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_active": { + "name": "last_active", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "billing_blocked": { + "name": "billing_blocked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "billing_blocked_reason": { + "name": "billing_blocked_reason", + "type": "billing_blocked_reason", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "limit_notifications": { + "name": "limit_notifications", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + } + }, + "indexes": {}, + "foreignKeys": { + "user_stats_user_id_user_id_fk": { + "name": "user_stats_user_id_user_id_fk", + "tableFrom": "user_stats", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_stats_user_id_unique": { + "name": "user_stats_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_table_definitions": { + "name": "user_table_definitions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "schema": { + "name": "schema", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "max_rows": { + "name": "max_rows", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10000 + }, + "row_count": { + "name": "row_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "rows_version": { + "name": "rows_version", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "schema_locked": { + "name": "schema_locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "insert_locked": { + "name": "insert_locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "update_locked": { + "name": "update_locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "delete_locked": { + "name": "delete_locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_table_def_workspace_id_idx": { + "name": "user_table_def_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_folder_id_idx": { + "name": "user_table_def_folder_id_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_workspace_name_unique": { + "name": "user_table_def_workspace_name_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"user_table_definitions\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_archived_at_idx": { + "name": "user_table_def_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_workspace_archived_partial_idx": { + "name": "user_table_def_workspace_archived_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"user_table_definitions\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_table_definitions_workspace_id_workspace_id_fk": { + "name": "user_table_definitions_workspace_id_workspace_id_fk", + "tableFrom": "user_table_definitions", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_table_definitions_folder_id_folder_id_fk": { + "name": "user_table_definitions_folder_id_folder_id_fk", + "tableFrom": "user_table_definitions", + "tableTo": "folder", + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "user_table_definitions_created_by_user_id_fk": { + "name": "user_table_definitions_created_by_user_id_fk", + "tableFrom": "user_table_definitions", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_table_row_secret_provenance": { + "name": "user_table_row_secret_provenance", + "schema": "", + "columns": { + "row_id": { + "name": "row_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "content_updated_at": { + "name": "content_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entries": { + "name": "entries", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_table_row_secret_provenance_row_id_user_table_rows_id_fk": { + "name": "user_table_row_secret_provenance_row_id_user_table_rows_id_fk", + "tableFrom": "user_table_row_secret_provenance", + "tableTo": "user_table_rows", + "columnsFrom": ["row_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "user_table_row_secret_provenance_status_check": { + "name": "user_table_row_secret_provenance_status_check", + "value": "\"user_table_row_secret_provenance\".\"status\" IN ('exact', 'unknown')" + } + }, + "isRLSEnabled": false + }, + "public.user_table_rows": { + "name": "user_table_rows", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "order_key": { + "name": "order_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "secret_provenance_version": { + "name": "secret_provenance_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "user_table_rows_tenant_data_gin_idx": { + "name": "user_table_rows_tenant_data_gin_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"data\" jsonb_path_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "user_table_rows_workspace_table_idx": { + "name": "user_table_rows_workspace_table_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_position_idx": { + "name": "user_table_rows_table_position_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_order_key_idx": { + "name": "user_table_rows_table_order_key_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "order_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_id_id_idx": { + "name": "user_table_rows_table_id_id_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_table_rows_table_id_user_table_definitions_id_fk": { + "name": "user_table_rows_table_id_user_table_definitions_id_fk", + "tableFrom": "user_table_rows", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_table_rows_workspace_id_workspace_id_fk": { + "name": "user_table_rows_workspace_id_workspace_id_fk", + "tableFrom": "user_table_rows", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_table_rows_created_by_user_id_fk": { + "name": "user_table_rows_created_by_user_id_fk", + "tableFrom": "user_table_rows", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "verification_expires_at_idx": { + "name": "verification_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.waitlist": { + "name": "waitlist", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "waitlist_email_unique": { + "name": "waitlist_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhook": { + "name": "webhook", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "registration_status": { + "name": "registration_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "registration_generation": { + "name": "registration_generation", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "config_fingerprint": { + "name": "config_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prepared_at": { + "name": "prepared_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "routing_key": { + "name": "routing_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_config": { + "name": "provider_config", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "failed_count": { + "name": "failed_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_failed_at": { + "name": "last_failed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "path_deployment_unique": { + "name": "path_deployment_unique", + "columns": [ + { + "expression": "path", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"webhook\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_workflow_deployment_idx": { + "name": "webhook_workflow_deployment_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_routing_key_active_idx": { + "name": "webhook_routing_key_active_idx", + "columns": [ + { + "expression": "routing_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"webhook\".\"archived_at\" IS NULL AND \"webhook\".\"routing_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_archived_at_partial_idx": { + "name": "webhook_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"webhook\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_webhook_on_provider_is_active_workflow_id_deploym_bdeed5468": { + "name": "idx_webhook_on_provider_is_active_workflow_id_deploym_bdeed5468", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_webhook_on_workflow_id_block_id_updated_at_desc": { + "name": "idx_webhook_on_workflow_id_block_id_updated_at_desc", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_active_registration_unique": { + "name": "webhook_active_registration_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"webhook\".\"registration_status\" = 'active' AND \"webhook\".\"block_id\" IS NOT NULL AND \"webhook\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_candidate_registration_unique": { + "name": "webhook_candidate_registration_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"webhook\".\"registration_status\" = 'candidate' AND \"webhook\".\"block_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_registration_status_generation_idx": { + "name": "webhook_registration_status_generation_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "registration_status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "registration_generation", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "webhook_workflow_id_workflow_id_fk": { + "name": "webhook_workflow_id_workflow_id_fk", + "tableFrom": "webhook", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "webhook_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "webhook_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "webhook", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "webhook_registration_status_check": { + "name": "webhook_registration_status_check", + "value": "\"webhook\".\"registration_status\" IS NULL OR \"webhook\".\"registration_status\" IN ('active', 'candidate', 'retired', 'orphaned')" + }, + "webhook_registration_generation_check": { + "name": "webhook_registration_generation_check", + "value": "\"webhook\".\"registration_generation\" IS NULL OR \"webhook\".\"registration_generation\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.webhook_path_claim": { + "name": "webhook_path_claim", + "schema": "", + "columns": { + "path": { + "name": "path", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "webhook_path_claim_workflow_idx": { + "name": "webhook_path_claim_workflow_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "webhook_path_claim_workflow_id_workflow_id_fk": { + "name": "webhook_path_claim_workflow_id_workflow_id_fk", + "tableFrom": "webhook_path_claim", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "webhook_path_claim_generation_check": { + "name": "webhook_path_claim_generation_check", + "value": "\"webhook_path_claim\".\"generation\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.workflow": { + "name": "workflow", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_synced": { + "name": "last_synced", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "is_deployed": { + "name": "is_deployed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deployed_at": { + "name": "deployed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "is_public_api": { + "name": "is_public_api", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "fork_sync_excluded": { + "name": "fork_sync_excluded", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "run_count": { + "name": "run_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workflow_user_id_idx": { + "name": "workflow_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_id_idx": { + "name": "workflow_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_user_workspace_idx": { + "name": "workflow_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_folder_name_active_unique": { + "name": "workflow_workspace_folder_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"folder_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_sort_idx": { + "name": "workflow_folder_sort_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_active_workspace_sort_idx": { + "name": "workflow_active_workspace_sort_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_archived_at_idx": { + "name": "workflow_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_archived_partial_idx": { + "name": "workflow_workspace_archived_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_user_id_user_id_fk": { + "name": "workflow_user_id_user_id_fk", + "tableFrom": "workflow", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_workspace_id_workspace_id_fk": { + "name": "workflow_workspace_id_workspace_id_fk", + "tableFrom": "workflow", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_folder_id_folder_id_fk": { + "name": "workflow_folder_id_folder_id_fk", + "tableFrom": "workflow", + "tableTo": "folder", + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_blocks": { + "name": "workflow_blocks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position_x": { + "name": "position_x", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "position_y": { + "name": "position_y", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "horizontal_handles": { + "name": "horizontal_handles", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "is_wide": { + "name": "is_wide", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "advanced_mode": { + "name": "advanced_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "trigger_mode": { + "name": "trigger_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "error_enabled": { + "name": "error_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "retry": { + "name": "retry", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "height": { + "name": "height", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "sub_blocks": { + "name": "sub_blocks", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "outputs": { + "name": "outputs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_blocks_workflow_id_idx": { + "name": "workflow_blocks_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_blocks_type_idx": { + "name": "workflow_blocks_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_blocks_workflow_id_workflow_id_fk": { + "name": "workflow_blocks_workflow_id_workflow_id_fk", + "tableFrom": "workflow_blocks", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_checkpoints": { + "name": "workflow_checkpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_state": { + "name": "workflow_state", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_checkpoints_user_id_idx": { + "name": "workflow_checkpoints_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_workflow_id_idx": { + "name": "workflow_checkpoints_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_chat_id_idx": { + "name": "workflow_checkpoints_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_message_id_idx": { + "name": "workflow_checkpoints_message_id_idx", + "columns": [ + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_user_workflow_idx": { + "name": "workflow_checkpoints_user_workflow_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_workflow_chat_idx": { + "name": "workflow_checkpoints_workflow_chat_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_created_at_idx": { + "name": "workflow_checkpoints_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_chat_created_at_idx": { + "name": "workflow_checkpoints_chat_created_at_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_checkpoints_user_id_user_id_fk": { + "name": "workflow_checkpoints_user_id_user_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_checkpoints_workflow_id_workflow_id_fk": { + "name": "workflow_checkpoints_workflow_id_workflow_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_checkpoints_chat_id_copilot_chats_id_fk": { + "name": "workflow_checkpoints_chat_id_copilot_chats_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_deployment_operation": { + "name": "workflow_deployment_operation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "previous_active_version_id": { + "name": "previous_active_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "protocol_version": { + "name": "protocol_version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'preparing'" + }, + "component_readiness": { + "name": "component_readiness", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "request_hash": { + "name": "request_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_deployment_operation_workflow_generation_unique": { + "name": "workflow_deployment_operation_workflow_generation_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "generation", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_idempotency_unique": { + "name": "workflow_deployment_operation_workflow_idempotency_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_deployment_operation\".\"idempotency_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_in_flight_unique": { + "name": "workflow_deployment_operation_workflow_in_flight_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_deployment_operation\".\"status\" IN ('preparing', 'activating')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_status_idx": { + "name": "workflow_deployment_operation_workflow_status_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_deployment_version_idx": { + "name": "workflow_deployment_operation_deployment_version_idx", + "columns": [ + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_version_generation_idx": { + "name": "workflow_deployment_operation_workflow_version_generation_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "generation", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_deployment_operation_workflow_id_workflow_id_fk": { + "name": "workflow_deployment_operation_workflow_id_workflow_id_fk", + "tableFrom": "workflow_deployment_operation", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_deployment_operation_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_deployment_operation_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_deployment_operation", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_deployment_operation_previous_active_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_deployment_operation_previous_active_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_deployment_operation", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["previous_active_version_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "workflow_deployment_operation_action_check": { + "name": "workflow_deployment_operation_action_check", + "value": "\"workflow_deployment_operation\".\"action\" IN ('deploy', 'activate')" + }, + "workflow_deployment_operation_status_check": { + "name": "workflow_deployment_operation_status_check", + "value": "\"workflow_deployment_operation\".\"status\" IN ('preparing', 'activating', 'active', 'failed', 'superseded')" + }, + "workflow_deployment_operation_generation_check": { + "name": "workflow_deployment_operation_generation_check", + "value": "\"workflow_deployment_operation\".\"generation\" > 0" + }, + "workflow_deployment_operation_protocol_version_check": { + "name": "workflow_deployment_operation_protocol_version_check", + "value": "\"workflow_deployment_operation\".\"protocol_version\" > 0" + } + }, + "isRLSEnabled": false + }, + "public.workflow_deployment_version": { + "name": "workflow_deployment_version", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workflow_deployment_version_workflow_version_unique": { + "name": "workflow_deployment_version_workflow_version_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_version_workflow_active_idx": { + "name": "workflow_deployment_version_workflow_active_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_version_created_at_idx": { + "name": "workflow_deployment_version_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_deployment_version_workflow_id_workflow_id_fk": { + "name": "workflow_deployment_version_workflow_id_workflow_id_fk", + "tableFrom": "workflow_deployment_version", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_edges": { + "name": "workflow_edges", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_block_id": { + "name": "source_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_block_id": { + "name": "target_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_handle": { + "name": "source_handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_handle": { + "name": "target_handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_edges_workflow_id_idx": { + "name": "workflow_edges_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_edges_workflow_source_idx": { + "name": "workflow_edges_workflow_source_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_edges_workflow_target_idx": { + "name": "workflow_edges_workflow_target_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_edges_workflow_id_workflow_id_fk": { + "name": "workflow_edges_workflow_id_workflow_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_edges_source_block_id_workflow_blocks_id_fk": { + "name": "workflow_edges_source_block_id_workflow_blocks_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow_blocks", + "columnsFrom": ["source_block_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_edges_target_block_id_workflow_blocks_id_fk": { + "name": "workflow_edges_target_block_id_workflow_blocks_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow_blocks", + "columnsFrom": ["target_block_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_execution_logs": { + "name": "workflow_execution_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state_snapshot_id": { + "name": "state_snapshot_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "execution_deadline_at": { + "name": "execution_deadline_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_duration_ms": { + "name": "total_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "execution_data": { + "name": "execution_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "cost": { + "name": "cost", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "cost_total": { + "name": "cost_total", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "models_used": { + "name": "models_used", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "files": { + "name": "files", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_execution_logs_workflow_id_idx": { + "name": "workflow_execution_logs_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_state_snapshot_id_idx": { + "name": "workflow_execution_logs_state_snapshot_id_idx", + "columns": [ + { + "expression": "state_snapshot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_deployment_version_id_idx": { + "name": "workflow_execution_logs_deployment_version_id_idx", + "columns": [ + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_trigger_idx": { + "name": "workflow_execution_logs_trigger_idx", + "columns": [ + { + "expression": "trigger", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_level_idx": { + "name": "workflow_execution_logs_level_idx", + "columns": [ + { + "expression": "level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_started_at_idx": { + "name": "workflow_execution_logs_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_execution_id_unique": { + "name": "workflow_execution_logs_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workflow_started_at_idx": { + "name": "workflow_execution_logs_workflow_started_at_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workspace_started_at_idx": { + "name": "workflow_execution_logs_workspace_started_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workspace_started_at_id_desc_idx": { + "name": "workflow_execution_logs_workspace_started_at_id_desc_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"started_at\" DESC NULLS LAST", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "\"id\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workspace_cost_total_idx": { + "name": "workflow_execution_logs_workspace_cost_total_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_total", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_models_used_idx": { + "name": "workflow_execution_logs_models_used_idx", + "columns": [ + { + "expression": "models_used", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "workflow_execution_logs_workspace_ended_at_id_idx": { + "name": "workflow_execution_logs_workspace_ended_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"ended_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_running_started_at_idx": { + "name": "workflow_execution_logs_running_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "status = 'running'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_running_deadline_idx": { + "name": "workflow_execution_logs_running_deadline_idx", + "columns": [ + { + "expression": "execution_deadline_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_execution_logs\".\"status\" = 'running' AND \"workflow_execution_logs\".\"execution_deadline_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_redacting_started_at_idx": { + "name": "workflow_execution_logs_redacting_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "status = 'redacting'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_redacting_deadline_idx": { + "name": "workflow_execution_logs_redacting_deadline_idx", + "columns": [ + { + "expression": "execution_deadline_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_execution_logs\".\"status\" = 'redacting' AND \"workflow_execution_logs\".\"execution_deadline_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_completed_ended_at_idx": { + "name": "workflow_execution_logs_completed_ended_at_idx", + "columns": [ + { + "expression": "ended_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_execution_logs\".\"status\" = 'completed' AND \"workflow_execution_logs\".\"level\" = 'info' AND \"workflow_execution_logs\".\"ended_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_execution_logs_workflow_id_workflow_id_fk": { + "name": "workflow_execution_logs_workflow_id_workflow_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workflow_execution_logs_workspace_id_workspace_id_fk": { + "name": "workflow_execution_logs_workspace_id_workspace_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_execution_logs_state_snapshot_id_workflow_execution_snapshots_id_fk": { + "name": "workflow_execution_logs_state_snapshot_id_workflow_execution_snapshots_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow_execution_snapshots", + "columnsFrom": ["state_snapshot_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "workflow_execution_logs_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_execution_logs_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_execution_snapshots": { + "name": "workflow_execution_snapshots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state_hash": { + "name": "state_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state_data": { + "name": "state_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_snapshots_workflow_id_idx": { + "name": "workflow_snapshots_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_hash_idx": { + "name": "workflow_snapshots_hash_idx", + "columns": [ + { + "expression": "state_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_workflow_hash_idx": { + "name": "workflow_snapshots_workflow_hash_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "state_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_created_at_idx": { + "name": "workflow_snapshots_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_execution_snapshots_workflow_id_workflow_id_fk": { + "name": "workflow_execution_snapshots_workflow_id_workflow_id_fk", + "tableFrom": "workflow_execution_snapshots", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_mcp_server": { + "name": "workflow_mcp_server", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_mcp_server_workspace_id_idx": { + "name": "workflow_mcp_server_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_server_created_by_idx": { + "name": "workflow_mcp_server_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_server_deleted_at_idx": { + "name": "workflow_mcp_server_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_server_workspace_deleted_partial_idx": { + "name": "workflow_mcp_server_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_mcp_server\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_mcp_server_workspace_id_workspace_id_fk": { + "name": "workflow_mcp_server_workspace_id_workspace_id_fk", + "tableFrom": "workflow_mcp_server", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_mcp_server_created_by_user_id_fk": { + "name": "workflow_mcp_server_created_by_user_id_fk", + "tableFrom": "workflow_mcp_server", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_mcp_tool": { + "name": "workflow_mcp_tool", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_description": { + "name": "tool_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parameter_schema": { + "name": "parameter_schema", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "parameter_description_overrides": { + "name": "parameter_description_overrides", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'::json" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_mcp_tool_server_id_idx": { + "name": "workflow_mcp_tool_server_id_idx", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_tool_workflow_id_idx": { + "name": "workflow_mcp_tool_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_tool_server_workflow_unique": { + "name": "workflow_mcp_tool_server_workflow_unique", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_mcp_tool\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_tool_archived_at_partial_idx": { + "name": "workflow_mcp_tool_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_mcp_tool\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_mcp_tool_server_id_workflow_mcp_server_id_fk": { + "name": "workflow_mcp_tool_server_id_workflow_mcp_server_id_fk", + "tableFrom": "workflow_mcp_tool", + "tableTo": "workflow_mcp_server", + "columnsFrom": ["server_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_mcp_tool_workflow_id_workflow_id_fk": { + "name": "workflow_mcp_tool_workflow_id_workflow_id_fk", + "tableFrom": "workflow_mcp_tool", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_schedule": { + "name": "workflow_schedule", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deployment_operation_id": { + "name": "deployment_operation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cron_expression": { + "name": "cron_expression", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_run_at": { + "name": "next_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_ran_at": { + "name": "last_ran_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_queued_at": { + "name": "last_queued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trigger_type": { + "name": "trigger_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'UTC'" + }, + "failed_count": { + "name": "failed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "infra_retry_count": { + "name": "infra_retry_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "last_failed_at": { + "name": "last_failed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "source_type": { + "name": "source_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'workflow'" + }, + "job_title": { + "name": "job_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lifecycle": { + "name": "lifecycle", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'persistent'" + }, + "success_condition": { + "name": "success_condition", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "max_runs": { + "name": "max_runs", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "run_count": { + "name": "run_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "source_chat_id": { + "name": "source_chat_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_task_name": { + "name": "source_task_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_user_id": { + "name": "source_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_workspace_id": { + "name": "source_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "secret_scope": { + "name": "secret_scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'all'" + }, + "mounted_secrets": { + "name": "mounted_secrets", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "job_history": { + "name": "job_history", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "contexts": { + "name": "contexts", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "excluded_dates": { + "name": "excluded_dates", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "ends_at": { + "name": "ends_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_schedule_workflow_block_deployment_unique": { + "name": "workflow_schedule_workflow_block_deployment_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_schedule\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_workflow_deployment_idx": { + "name": "workflow_schedule_workflow_deployment_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_archived_at_partial_idx": { + "name": "workflow_schedule_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_schedule\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workflow_schedule_on_source_workspace_id_source_t_c07f3bba6": { + "name": "idx_workflow_schedule_on_source_workspace_id_source_t_c07f3bba6", + "columns": [ + { + "expression": "source_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_due_workflow_idx": { + "name": "workflow_schedule_due_workflow_idx", + "columns": [ + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_schedule\".\"archived_at\" IS NULL AND \"workflow_schedule\".\"status\" NOT IN ('disabled', 'completed') AND (\"workflow_schedule\".\"source_type\" = 'workflow' OR \"workflow_schedule\".\"source_type\" IS NULL)", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_due_job_idx": { + "name": "workflow_schedule_due_job_idx", + "columns": [ + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_schedule\".\"archived_at\" IS NULL AND \"workflow_schedule\".\"status\" NOT IN ('disabled', 'completed') AND \"workflow_schedule\".\"source_type\" = 'job'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_schedule_workflow_id_workflow_id_fk": { + "name": "workflow_schedule_workflow_id_workflow_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_schedule_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_deployment_operation_id_workflow_deployment_operation_id_fk": { + "name": "workflow_schedule_deployment_operation_id_workflow_deployment_operation_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow_deployment_operation", + "columnsFrom": ["deployment_operation_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workflow_schedule_source_user_id_user_id_fk": { + "name": "workflow_schedule_source_user_id_user_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "user", + "columnsFrom": ["source_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_source_workspace_id_workspace_id_fk": { + "name": "workflow_schedule_source_workspace_id_workspace_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workspace", + "columnsFrom": ["source_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_subflows": { + "name": "workflow_subflows", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_subflows_workflow_id_idx": { + "name": "workflow_subflows_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_subflows_workflow_type_idx": { + "name": "workflow_subflows_workflow_type_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_subflows_workflow_id_workflow_id_fk": { + "name": "workflow_subflows_workflow_id_workflow_id_fk", + "tableFrom": "workflow_subflows", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace": { + "name": "workspace", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'#33C482'" + }, + "logo_url": { + "name": "logo_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_mode": { + "name": "workspace_mode", + "type": "workspace_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'grandfathered_shared'" + }, + "billed_account_user_id": { + "name": "billed_account_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "allow_personal_api_keys": { + "name": "allow_personal_api_keys", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "inbox_enabled": { + "name": "inbox_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "inbox_address": { + "name": "inbox_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "inbox_provider_id": { + "name": "inbox_provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "inbox_secret_scope": { + "name": "inbox_secret_scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'all'" + }, + "inbox_mounted_secrets": { + "name": "inbox_mounted_secrets", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "organization_assigned_at": { + "name": "organization_assigned_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "forked_from_workspace_id": { + "name": "forked_from_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_owner_id_idx": { + "name": "workspace_owner_id_idx", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_organization_id_idx": { + "name": "workspace_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_mode_idx": { + "name": "workspace_mode_idx", + "columns": [ + { + "expression": "workspace_mode", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_forked_from_workspace_id_idx": { + "name": "workspace_forked_from_workspace_id_idx", + "columns": [ + { + "expression": "forked_from_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_inbox_provider_id_idx": { + "name": "workspace_inbox_provider_id_idx", + "columns": [ + { + "expression": "inbox_provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace\".\"inbox_provider_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_owner_id_user_id_fk": { + "name": "workspace_owner_id_user_id_fk", + "tableFrom": "workspace", + "tableTo": "user", + "columnsFrom": ["owner_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_organization_id_organization_id_fk": { + "name": "workspace_organization_id_organization_id_fk", + "tableFrom": "workspace", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspace_billed_account_user_id_user_id_fk": { + "name": "workspace_billed_account_user_id_user_id_fk", + "tableFrom": "workspace", + "tableTo": "user", + "columnsFrom": ["billed_account_user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "workspace_forked_from_workspace_id_workspace_id_fk": { + "name": "workspace_forked_from_workspace_id_workspace_id_fk", + "tableFrom": "workspace", + "tableTo": "workspace", + "columnsFrom": ["forked_from_workspace_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "workspace_storage_used_bytes_non_negative": { + "name": "workspace_storage_used_bytes_non_negative", + "value": "\"workspace\".\"storage_used_bytes\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.workspace_byok_keys": { + "name": "workspace_byok_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_api_key": { + "name": "encrypted_api_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_byok_workspace_provider_idx": { + "name": "workspace_byok_workspace_provider_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_byok_keys_workspace_id_workspace_id_fk": { + "name": "workspace_byok_keys_workspace_id_workspace_id_fk", + "tableFrom": "workspace_byok_keys", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_byok_keys_created_by_user_id_fk": { + "name": "workspace_byok_keys_created_by_user_id_fk", + "tableFrom": "workspace_byok_keys", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_environment": { + "name": "workspace_environment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_environment_workspace_unique": { + "name": "workspace_environment_workspace_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_environment_workspace_id_workspace_id_fk": { + "name": "workspace_environment_workspace_id_workspace_id_fk", + "tableFrom": "workspace_environment", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file": { + "name": "workspace_file", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_file_workspace_id_idx": { + "name": "workspace_file_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_deleted_at_idx": { + "name": "workspace_file_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_workspace_deleted_partial_idx": { + "name": "workspace_file_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_file\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_file_workspace_id_workspace_id_fk": { + "name": "workspace_file_workspace_id_workspace_id_fk", + "tableFrom": "workspace_file", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_file_uploaded_by_user_id_fk": { + "name": "workspace_file_uploaded_by_user_id_fk", + "tableFrom": "workspace_file", + "tableTo": "user", + "columnsFrom": ["uploaded_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workspace_file_key_unique": { + "name": "workspace_file_key_unique", + "nullsNotDistinct": false, + "columns": ["key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file_collab_state": { + "name": "workspace_file_collab_state", + "schema": "", + "columns": { + "file_id": { + "name": "file_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "doc_state": { + "name": "doc_state", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "source_hash": { + "name": "source_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "workspace_file_collab_state_file_id_workspace_files_id_fk": { + "name": "workspace_file_collab_state_file_id_workspace_files_id_fk", + "tableFrom": "workspace_file_collab_state", + "tableTo": "workspace_files", + "columnsFrom": ["file_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file_secret_provenance": { + "name": "workspace_file_secret_provenance", + "schema": "", + "columns": { + "file_id": { + "name": "file_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "content_updated_at": { + "name": "content_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entries": { + "name": "entries", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "workspace_file_secret_provenance_file_id_workspace_files_id_fk": { + "name": "workspace_file_secret_provenance_file_id_workspace_files_id_fk", + "tableFrom": "workspace_file_secret_provenance", + "tableTo": "workspace_files", + "columnsFrom": ["file_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "workspace_file_secret_provenance_status_check": { + "name": "workspace_file_secret_provenance_status_check", + "value": "\"workspace_file_secret_provenance\".\"status\" IN ('exact', 'unknown', 'unrecorded')" + } + }, + "isRLSEnabled": false + }, + "public.workspace_files": { + "name": "workspace_files", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "context": { + "name": "context", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "original_name": { + "name": "original_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "size_bytes": { + "name": "size_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "content_updated_at": { + "name": "content_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "secret_provenance_version": { + "name": "secret_provenance_version", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workspace_files_key_active_unique": { + "name": "workspace_files_key_active_unique", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_files\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_workspace_folder_name_active_unique": { + "name": "workspace_files_workspace_folder_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"folder_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "original_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_files\".\"deleted_at\" IS NULL AND \"workspace_files\".\"context\" = 'workspace' AND \"workspace_files\".\"workspace_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_chat_display_name_unique": { + "name": "workspace_files_chat_display_name_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "display_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_files\".\"context\" = 'mothership' AND \"workspace_files\".\"chat_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_key_idx": { + "name": "workspace_files_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_user_id_idx": { + "name": "workspace_files_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_workspace_id_idx": { + "name": "workspace_files_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_folder_id_idx": { + "name": "workspace_files_folder_id_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_context_idx": { + "name": "workspace_files_context_idx", + "columns": [ + { + "expression": "context", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_chat_id_idx": { + "name": "workspace_files_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_deleted_at_idx": { + "name": "workspace_files_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_workspace_deleted_partial_idx": { + "name": "workspace_files_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_files\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_files_user_id_user_id_fk": { + "name": "workspace_files_user_id_user_id_fk", + "tableFrom": "workspace_files", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_files_workspace_id_workspace_id_fk": { + "name": "workspace_files_workspace_id_workspace_id_fk", + "tableFrom": "workspace_files", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_files_folder_id_folder_id_fk": { + "name": "workspace_files_folder_id_folder_id_fk", + "tableFrom": "workspace_files", + "tableTo": "folder", + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspace_files_chat_id_copilot_chats_id_fk": { + "name": "workspace_files_chat_id_copilot_chats_id_fk", + "tableFrom": "workspace_files", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_block_map": { + "name": "workspace_fork_block_map", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_workflow_id": { + "name": "parent_workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_block_id": { + "name": "parent_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_workflow_id": { + "name": "child_workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_block_id": { + "name": "child_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_block_map_child_ws_parent_unique": { + "name": "workspace_fork_block_map_child_ws_parent_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_block_map_child_ws_child_unique": { + "name": "workspace_fork_block_map_child_ws_child_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "child_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_block_map_child_ws_parent_wf_idx": { + "name": "workspace_fork_block_map_child_ws_parent_wf_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_block_map_child_ws_child_wf_idx": { + "name": "workspace_fork_block_map_child_ws_child_wf_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "child_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_block_map_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_block_map_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_block_map", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_dependent_value": { + "name": "workspace_fork_dependent_value", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_workflow_id": { + "name": "target_workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_block_id": { + "name": "target_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sub_block_key": { + "name": "sub_block_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_dependent_value_child_ws_wf_idx": { + "name": "workspace_fork_dependent_value_child_ws_wf_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_dependent_value_field_unique": { + "name": "workspace_fork_dependent_value_field_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sub_block_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_dependent_value_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_dependent_value_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_dependent_value", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_promote_run": { + "name": "workspace_fork_promote_run", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_workspace_id": { + "name": "source_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_workspace_id": { + "name": "target_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "direction": { + "name": "direction", + "type": "workspace_fork_promote_direction", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "snapshot": { + "name": "snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_promote_run_child_ws_target_unique": { + "name": "workspace_fork_promote_run_child_ws_target_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_promote_run_target_ws_idx": { + "name": "workspace_fork_promote_run_target_ws_idx", + "columns": [ + { + "expression": "target_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_promote_run_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_promote_run_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_promote_run", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_fork_promote_run_created_by_user_id_fk": { + "name": "workspace_fork_promote_run_created_by_user_id_fk", + "tableFrom": "workspace_fork_promote_run", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_resource_map": { + "name": "workspace_fork_resource_map", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "workspace_fork_resource_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "parent_resource_id": { + "name": "parent_resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_resource_id": { + "name": "child_resource_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_resource_map_child_ws_idx": { + "name": "workspace_fork_resource_map_child_ws_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_resource_map_child_ws_type_idx": { + "name": "workspace_fork_resource_map_child_ws_type_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_resource_map_child_type_parent_unique": { + "name": "workspace_fork_resource_map_child_type_parent_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_resource_map_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_resource_map_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_resource_map", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_fork_resource_map_created_by_user_id_fk": { + "name": "workspace_fork_resource_map_created_by_user_id_fk", + "tableFrom": "workspace_fork_resource_map", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_sandbox": { + "name": "workspace_sandbox", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "language": { + "name": "language", + "type": "sandbox_language", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "dependencies": { + "name": "dependencies", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "cli_tools": { + "name": "cli_tools", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "system_packages": { + "name": "system_packages", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "spec_hash": { + "name": "spec_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_sandbox_workspace_name_unique": { + "name": "workspace_sandbox_workspace_name_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_sandbox_workspace_idx": { + "name": "workspace_sandbox_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_sandbox_spec_hash_idx": { + "name": "workspace_sandbox_spec_hash_idx", + "columns": [ + { + "expression": "spec_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_sandbox_workspace_id_workspace_id_fk": { + "name": "workspace_sandbox_workspace_id_workspace_id_fk", + "tableFrom": "workspace_sandbox", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_sandbox_created_by_user_id_fk": { + "name": "workspace_sandbox_created_by_user_id_fk", + "tableFrom": "workspace_sandbox", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.academy_cert_status": { + "name": "academy_cert_status", + "schema": "public", + "values": ["active", "revoked", "expired"] + }, + "public.background_work_kind": { + "name": "background_work_kind", + "schema": "public", + "values": ["deployment_side_effects", "fork_content_copy", "fork_sync", "fork_rollback"] + }, + "public.background_work_status_value": { + "name": "background_work_status_value", + "schema": "public", + "values": ["pending", "processing", "completed", "completed_with_warnings", "failed"] + }, + "public.billing_blocked_reason": { + "name": "billing_blocked_reason", + "schema": "public", + "values": ["payment_failed", "dispute"] + }, + "public.billing_entity_type": { + "name": "billing_entity_type", + "schema": "public", + "values": ["user", "organization"] + }, + "public.chat_type": { + "name": "chat_type", + "schema": "public", + "values": ["mothership", "copilot"] + }, + "public.copilot_async_tool_status": { + "name": "copilot_async_tool_status", + "schema": "public", + "values": ["pending", "running", "completed", "failed", "cancelled", "delivered"] + }, + "public.copilot_run_status": { + "name": "copilot_run_status", + "schema": "public", + "values": ["active", "paused_waiting_for_tool", "resuming", "complete", "error", "cancelled"] + }, + "public.copilot_tool_permission_decision": { + "name": "copilot_tool_permission_decision", + "schema": "public", + "values": ["allow", "allow_chat", "always_allow", "skip"] + }, + "public.credential_group_enrollment_status": { + "name": "credential_group_enrollment_status", + "schema": "public", + "values": ["invited", "delivery_failed", "in_progress", "completed", "revoked"] + }, + "public.credential_group_status": { + "name": "credential_group_status", + "schema": "public", + "values": ["active", "disabled"] + }, + "public.credential_member_role": { + "name": "credential_member_role", + "schema": "public", + "values": ["admin", "member"] + }, + "public.credential_member_status": { + "name": "credential_member_status", + "schema": "public", + "values": ["active", "pending", "revoked"] + }, + "public.credential_type": { + "name": "credential_type", + "schema": "public", + "values": ["oauth", "managed_oauth", "env_workspace", "env_personal", "service_account"] + }, + "public.data_drain_cadence": { + "name": "data_drain_cadence", + "schema": "public", + "values": ["hourly", "daily"] + }, + "public.data_drain_destination": { + "name": "data_drain_destination", + "schema": "public", + "values": ["s3", "gcs", "azure_blob", "datadog", "bigquery", "snowflake", "webhook"] + }, + "public.data_drain_run_status": { + "name": "data_drain_run_status", + "schema": "public", + "values": ["running", "success", "failed"] + }, + "public.data_drain_run_trigger": { + "name": "data_drain_run_trigger", + "schema": "public", + "values": ["cron", "manual"] + }, + "public.data_drain_source": { + "name": "data_drain_source", + "schema": "public", + "values": ["workflow_logs", "job_logs", "audit_logs", "copilot_chats", "copilot_runs"] + }, + "public.execution_large_value_reference_source": { + "name": "execution_large_value_reference_source", + "schema": "public", + "values": ["execution_log", "paused_snapshot"] + }, + "public.folder_resource_type": { + "name": "folder_resource_type", + "schema": "public", + "values": ["workflow", "file", "knowledge_base", "table"] + }, + "public.invitation_kind": { + "name": "invitation_kind", + "schema": "public", + "values": ["organization", "workspace"] + }, + "public.invitation_membership_intent": { + "name": "invitation_membership_intent", + "schema": "public", + "values": ["internal", "external"] + }, + "public.invitation_status": { + "name": "invitation_status", + "schema": "public", + "values": ["pending", "accepted", "rejected", "cancelled", "expired"] + }, + "public.managed_oauth_credential_status": { + "name": "managed_oauth_credential_status", + "schema": "public", + "values": ["active", "needs_reauth", "revoked"] + }, + "public.permission_type": { + "name": "permission_type", + "schema": "public", + "values": ["admin", "write", "read"] + }, + "public.sandbox_image_status": { + "name": "sandbox_image_status", + "schema": "public", + "values": ["pending", "building", "ready", "failed"] + }, + "public.sandbox_language": { + "name": "sandbox_language", + "schema": "public", + "values": ["javascript", "python"] + }, + "public.secret_usage_scope": { + "name": "secret_usage_scope", + "schema": "public", + "values": ["workspace", "personal"] + }, + "public.secret_usage_source": { + "name": "secret_usage_source", + "schema": "public", + "values": ["workflow", "copilot", "mcp"] + }, + "public.upload_session_method": { + "name": "upload_session_method", + "schema": "public", + "values": ["put", "multipart"] + }, + "public.upload_session_provider": { + "name": "upload_session_provider", + "schema": "public", + "values": ["local", "s3", "blob", "gcs"] + }, + "public.upload_session_purpose": { + "name": "upload_session_purpose", + "schema": "public", + "values": [ + "workspace_file", + "table_import", + "knowledge_document", + "profile_picture", + "workspace_logo", + "mothership_attachment", + "execution_attachment" + ] + }, + "public.upload_session_status": { + "name": "upload_session_status", + "schema": "public", + "values": [ + "uploading", + "completing", + "finalizing", + "completed", + "aborting", + "aborted", + "failed", + "expired" + ] + }, + "public.usage_log_category": { + "name": "usage_log_category", + "schema": "public", + "values": ["model", "fixed", "tool"] + }, + "public.usage_log_source": { + "name": "usage_log_source", + "schema": "public", + "values": [ + "workflow", + "wand", + "copilot", + "workspace-chat", + "mcp_copilot", + "mothership_block", + "knowledge-base", + "voice-input", + "enrichment", + "voice-output" + ] + }, + "public.workspace_fork_promote_direction": { + "name": "workspace_fork_promote_direction", + "schema": "public", + "values": ["push", "pull"] + }, + "public.workspace_fork_resource_type": { + "name": "workspace_fork_resource_type", + "schema": "public", + "values": [ + "workflow", + "oauth_credential", + "service_account_credential", + "env_var", + "table", + "knowledge_base", + "knowledge_document", + "file", + "mcp_server", + "workflow_mcp_server", + "custom_block", + "custom_tool", + "skill" + ] + }, + "public.workspace_mode": { + "name": "workspace_mode", + "schema": "public", + "values": ["personal", "organization", "grandfathered_shared"] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/db/migrations/meta/_journal.json b/packages/db/migrations/meta/_journal.json index d704f4f261d..a782818c124 100644 --- a/packages/db/migrations/meta/_journal.json +++ b/packages/db/migrations/meta/_journal.json @@ -2136,6 +2136,13 @@ "when": 1787687900983, "tag": "0305_add_subscription_last_closed_period_start", "breakpoints": true + }, + { + "idx": 306, + "version": "7", + "when": 1787702824793, + "tag": "0306_knowledge_pipeline_hardening", + "breakpoints": true } ] } diff --git a/packages/db/schema.ts b/packages/db/schema.ts index 018aad5c80d..2ea08bc4769 100644 --- a/packages/db/schema.ts +++ b/packages/db/schema.ts @@ -2564,7 +2564,11 @@ export const document = pgTable( * this column existed. */ processingQueuedAt: timestamp('processing_queued_at'), + /** Opaque dispatch generation; NULL identifies payloads created before token rollout. */ + processingQueueToken: text('processing_queue_token'), processingStartedAt: timestamp('processing_started_at'), + /** Scheduled execution time of an accepted durable quota continuation. */ + processingDeferredUntil: timestamp('processing_deferred_until'), processingCompletedAt: timestamp('processing_completed_at'), processingError: text('processing_error'), @@ -4431,6 +4435,7 @@ export const knowledgeConnectorSyncLog = pgTable( docsUpdated: integer('docs_updated').notNull().default(0), docsDeleted: integer('docs_deleted').notNull().default(0), docsUnchanged: integer('docs_unchanged').notNull().default(0), + docsSkipped: integer('docs_skipped').notNull().default(0), docsFailed: integer('docs_failed').notNull().default(0), errorMessage: text('error_message'), }, diff --git a/packages/sim-cli/src/generated/v2-api.ts b/packages/sim-cli/src/generated/v2-api.ts index 67964b4a089..0ceffb769a3 100644 --- a/packages/sim-cli/src/generated/v2-api.ts +++ b/packages/sim-cli/src/generated/v2-api.ts @@ -3822,6 +3822,7 @@ type GetKnowledgeConnectorResponseRef0 = { docsUpdated: number docsDeleted: number docsUnchanged: number + docsSkipped: number docsFailed: number errorMessage: string | null } diff --git a/packages/testing/src/mocks/schema.mock.ts b/packages/testing/src/mocks/schema.mock.ts index 061063a1627..d3059f4276d 100644 --- a/packages/testing/src/mocks/schema.mock.ts +++ b/packages/testing/src/mocks/schema.mock.ts @@ -763,7 +763,9 @@ export const schemaMock = { processingStatus: 'document.processingStatus', processingAttempts: 'document.processingAttempts', processingQueuedAt: 'document.processingQueuedAt', + processingQueueToken: 'document.processingQueueToken', processingStartedAt: 'document.processingStartedAt', + processingDeferredUntil: 'document.processingDeferredUntil', processingCompletedAt: 'document.processingCompletedAt', processingError: 'document.processingError', enabled: 'document.enabled', From 3977e468a58c4c664923d2459b4eb673121d1284 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 25 Aug 2026 14:03:00 -0700 Subject: [PATCH 2/5] fix(knowledge): correct bounded ingestion edge cases --- .../lib/chunkers/json-yaml-chunker.test.ts | 16 +++++++++ apps/sim/lib/chunkers/json-yaml-chunker.ts | 33 +++++++++++-------- .../chunkers/structured-data-chunker.test.ts | 2 ++ .../lib/chunkers/structured-data-chunker.ts | 13 +++++++- apps/sim/lib/chunkers/utils.ts | 29 +++++++++++++--- apps/sim/lib/core/security/redaction.test.ts | 9 +++++ apps/sim/lib/core/security/redaction.ts | 4 +-- .../sim/lib/knowledge/documents/utils.test.ts | 9 +++++ apps/sim/lib/knowledge/documents/utils.ts | 6 +++- 9 files changed, 100 insertions(+), 21 deletions(-) diff --git a/apps/sim/lib/chunkers/json-yaml-chunker.test.ts b/apps/sim/lib/chunkers/json-yaml-chunker.test.ts index 66d49cb967a..16a48781a28 100644 --- a/apps/sim/lib/chunkers/json-yaml-chunker.test.ts +++ b/apps/sim/lib/chunkers/json-yaml-chunker.test.ts @@ -368,6 +368,22 @@ server: }) describe('chunk metadata', () => { + it('preserves source offsets when oversized chunks are trimmed at boundaries', 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 + ) + } + }) + it.concurrent('should include startIndex and endIndex in metadata', async () => { const chunker = new JsonYamlChunker({ chunkSize: 100 }) const json = JSON.stringify({ key: 'value' }) diff --git a/apps/sim/lib/chunkers/json-yaml-chunker.ts b/apps/sim/lib/chunkers/json-yaml-chunker.ts index 9ccac753db1..8b9ceb5a4ee 100644 --- a/apps/sim/lib/chunkers/json-yaml-chunker.ts +++ b/apps/sim/lib/chunkers/json-yaml-chunker.ts @@ -5,7 +5,7 @@ import type { Chunk, ChunkerOptions } from '@/lib/chunkers/types' import { estimateTokens, iterateLines, - iterateWordBoundaryChunks, + iterateWordBoundaryChunkSpans, tokensToChars, } from '@/lib/chunkers/utils' @@ -261,14 +261,18 @@ export class JsonYamlChunker { return } - let startIndex = chunk.metadata.startIndex - for (const segment of iterateWordBoundaryChunks(chunk.text, tokensToChars(this.chunkSize))) { + for (const segment of iterateWordBoundaryChunkSpans( + chunk.text, + tokensToChars(this.chunkSize) + )) { budget.add(chunks, { - text: segment, - tokenCount: estimateTokens(segment), - metadata: { startIndex, endIndex: startIndex + segment.length }, + text: segment.text, + tokenCount: estimateTokens(segment.text), + metadata: { + startIndex: chunk.metadata.startIndex + segment.startIndex, + endIndex: chunk.metadata.startIndex + segment.endIndex, + }, }) - startIndex += segment.length } } @@ -291,15 +295,18 @@ export class JsonYamlChunker { currentChunk = '' currentTokens = 0 } - for (const segment of iterateWordBoundaryChunks(line, tokensToChars(this.chunkSize))) { + const lineStartIndex = startIndex + for (const segment of iterateWordBoundaryChunkSpans(line, tokensToChars(this.chunkSize))) { budget.add(chunks, { - text: segment, - tokenCount: estimateTokens(segment), - metadata: { startIndex, endIndex: startIndex + segment.length }, + text: segment.text, + tokenCount: estimateTokens(segment.text), + metadata: { + startIndex: lineStartIndex + segment.startIndex, + endIndex: lineStartIndex + segment.endIndex, + }, }) - startIndex += segment.length } - startIndex += 1 + startIndex += line.length + 1 continue } diff --git a/apps/sim/lib/chunkers/structured-data-chunker.test.ts b/apps/sim/lib/chunkers/structured-data-chunker.test.ts index cfff1c5acb8..e9f7aaa8bd6 100644 --- a/apps/sim/lib/chunkers/structured-data-chunker.test.ts +++ b/apps/sim/lib/chunkers/structured-data-chunker.test.ts @@ -153,6 +153,8 @@ Bob,25` expect(chunks.every((chunk) => chunk.tokenCount <= 1024)).toBe(true) expect(chunks.some((chunk) => chunk.text.includes('HEADER-'))).toBe(true) expect(chunks.some((chunk) => chunk.text.includes('ROW-'))).toBe(true) + expect(chunks.every((chunk) => !chunk.text.includes('Headers:'))).toBe(true) + expect(chunks.every((chunk) => !chunk.text.includes('rows of data'))).toBe(true) }) it('does not let the minimum row target exceed the token target', async () => { diff --git a/apps/sim/lib/chunkers/structured-data-chunker.ts b/apps/sim/lib/chunkers/structured-data-chunker.ts index d6d74ca2d99..0a18699b12c 100644 --- a/apps/sim/lib/chunkers/structured-data-chunker.ts +++ b/apps/sim/lib/chunkers/structured-data-chunker.ts @@ -53,6 +53,7 @@ export class StructuredDataChunker { let currentTokenEstimate = 0 const headerTokens = estimateStructuredTokens(headerLine) let chunkStartRow = dataStartIndex + let oversizedHeaderEmitted = false let lineIndex = 0 for (const row of iterateLines(content)) { @@ -78,7 +79,17 @@ export class StructuredDataChunker { StructuredDataChunker.formatChunk(headerLine, [''], options.sheetName) ) if (emptyRowOverhead >= targetChunkSize) { - for (const segment of iterateWordBoundaryChunks(standaloneRow, targetChunkSize * 3)) { + if (!oversizedHeaderEmitted) { + const headerContent = options.sheetName + ? `${options.sheetName}\n${headerLine}` + : headerLine + const headerRow = Math.max(0, dataStartIndex - 1) + for (const segment of iterateWordBoundaryChunks(headerContent, targetChunkSize * 3)) { + budget.add(chunks, StructuredDataChunker.createChunk(segment, headerRow, headerRow)) + } + oversizedHeaderEmitted = true + } + for (const segment of iterateWordBoundaryChunks(row, targetChunkSize * 3)) { budget.add(chunks, StructuredDataChunker.createChunk(segment, i, i)) } chunkStartRow = i + 1 diff --git a/apps/sim/lib/chunkers/utils.ts b/apps/sim/lib/chunkers/utils.ts index 8acc59dc74f..ccc6b066f8b 100644 --- a/apps/sim/lib/chunkers/utils.ts +++ b/apps/sim/lib/chunkers/utils.ts @@ -63,11 +63,18 @@ export function splitAtWordBoundaries( return Array.from(iterateWordBoundaryChunks(text, chunkSizeChars, stepChars)) } -export function* iterateWordBoundaryChunks( +export interface WordBoundaryChunkSpan { + text: string + startIndex: number + endIndex: number +} + +/** Iterates trimmed word-boundary chunks while preserving their source offsets. */ +export function* iterateWordBoundaryChunkSpans( text: string, chunkSizeChars: number, stepChars?: number -): Generator { +): Generator { let pos = 0 while (pos < text.length) { @@ -78,8 +85,12 @@ export function* iterateWordBoundaryChunks( if (lastSpace > pos) end = lastSpace } - const part = text.slice(pos, end).trim() - if (part) yield 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) { const nextPos = pos + Math.max(1, stepChars) @@ -93,6 +104,16 @@ export function* iterateWordBoundaryChunks( } } +export function* iterateWordBoundaryChunks( + text: string, + chunkSizeChars: number, + stepChars?: number +): Generator { + 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 { if (!separator) { diff --git a/apps/sim/lib/core/security/redaction.test.ts b/apps/sim/lib/core/security/redaction.test.ts index 334e4d082c6..6552f153214 100644 --- a/apps/sim/lib/core/security/redaction.test.ts +++ b/apps/sim/lib/core/security/redaction.test.ts @@ -195,6 +195,15 @@ describe('redactSensitiveValues', () => { expect(result).toContain('scope%3Dx') }) + it.concurrent('redacts authorization schemes without exposing the credential suffix', () => { + expect(redactSensitiveValues('authorization=Bearer token123 scope=openid')).toBe( + 'authorization=[REDACTED] scope=openid' + ) + expect(redactSensitiveValues('authorization=Basic dXNlcjpwYXNz')).toBe( + 'authorization=[REDACTED]' + ) + }) + it.concurrent('uses the canonical sensitive-key policy for form fields', () => { const keys = ['authorization', 'auth', 'bearer', 'private_key', 'passphrase'] const input = keys diff --git a/apps/sim/lib/core/security/redaction.ts b/apps/sim/lib/core/security/redaction.ts index 303d55a364f..0eb3090bd73 100644 --- a/apps/sim/lib/core/security/redaction.ts +++ b/apps/sim/lib/core/security/redaction.ts @@ -75,8 +75,8 @@ const SENSITIVE_VALUE_PATTERNS: Array<{ const FORM_FIELD_MARKER_PATTERN = /\b([A-Za-z0-9_-]+)=/gi const ENCODED_FORM_FIELD_MARKER_PATTERN = /\b([A-Za-z0-9_-]+)%3D/gi -const FORM_VALUE_DELIMITER_PATTERN = /&|\s/g -const ENCODED_FORM_VALUE_DELIMITER_PATTERN = /%26|&|\s/gi +const FORM_VALUE_DELIMITER_PATTERN = /&|\s+(?=[A-Za-z0-9_-]+(?:=|%3D))/gi +const ENCODED_FORM_VALUE_DELIMITER_PATTERN = /%26|&|\s+(?=[A-Za-z0-9_-]+(?:=|%3D))/gi interface SensitiveValueSpan { start: number diff --git a/apps/sim/lib/knowledge/documents/utils.test.ts b/apps/sim/lib/knowledge/documents/utils.test.ts index ff5b6043f31..19a80cb042b 100644 --- a/apps/sim/lib/knowledge/documents/utils.test.ts +++ b/apps/sim/lib/knowledge/documents/utils.test.ts @@ -64,6 +64,15 @@ describe('readBoundedHttpErrorPayload', () => { expect(result).toEqual({ ok: false, reason: 'too_large' }) expect(cancelled).toBe(true) }) + + it('reports an unreadable body as unavailable without observed size evidence', async () => { + const result = await readBoundedHttpErrorPayload({ + body: null, + headers: { get: () => null }, + }) + + expect(result).toEqual({ ok: false, reason: 'unavailable' }) + }) }) describe('isRetryableError', () => { diff --git a/apps/sim/lib/knowledge/documents/utils.ts b/apps/sim/lib/knowledge/documents/utils.ts index 50ad0039f8e..90abe05c0c6 100644 --- a/apps/sim/lib/knowledge/documents/utils.ts +++ b/apps/sim/lib/knowledge/documents/utils.ts @@ -99,7 +99,11 @@ export async function readBoundedHttpErrorPayload(response: { }), } } catch (error) { - if (isPayloadSizeLimitError(error)) { + if ( + isPayloadSizeLimitError(error) && + error.observedBytes !== undefined && + error.observedBytes > error.maxBytes + ) { return { ok: false, reason: 'too_large' } } return { ok: false, reason: 'unavailable' } From 0e915479b5030bdbdbba3025e216b427a53c6f37 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 25 Aug 2026 15:00:41 -0700 Subject: [PATCH 3/5] fix(knowledge): tighten redaction and chunk validation --- .../lib/chunkers/json-yaml-chunker.test.ts | 85 +++- apps/sim/lib/chunkers/json-yaml-chunker.ts | 52 ++- .../chunkers/structured-data-chunker.test.ts | 57 ++- .../lib/chunkers/structured-data-chunker.ts | 31 +- apps/sim/lib/chunkers/utils.test.ts | 42 ++ apps/sim/lib/chunkers/utils.ts | 38 +- apps/sim/lib/core/security/redaction.test.ts | 181 ++++++++- apps/sim/lib/core/security/redaction.ts | 367 ++++++++++++++---- .../sim/lib/knowledge/documents/utils.test.ts | 108 +----- 9 files changed, 769 insertions(+), 192 deletions(-) diff --git a/apps/sim/lib/chunkers/json-yaml-chunker.test.ts b/apps/sim/lib/chunkers/json-yaml-chunker.test.ts index 16a48781a28..91726186b88 100644 --- a/apps/sim/lib/chunkers/json-yaml-chunker.test.ts +++ b/apps/sim/lib/chunkers/json-yaml-chunker.test.ts @@ -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) @@ -368,7 +391,7 @@ server: }) describe('chunk metadata', () => { - it('preserves source offsets when oversized chunks are trimmed at boundaries', async () => { + 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)}` @@ -382,6 +405,66 @@ server: 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 () => { diff --git a/apps/sim/lib/chunkers/json-yaml-chunker.ts b/apps/sim/lib/chunkers/json-yaml-chunker.ts index 8b9ceb5a4ee..3568132120e 100644 --- a/apps/sim/lib/chunkers/json-yaml-chunker.ts +++ b/apps/sim/lib/chunkers/json-yaml-chunker.ts @@ -5,7 +5,8 @@ import type { Chunk, ChunkerOptions } from '@/lib/chunkers/types' import { estimateTokens, iterateLines, - iterateWordBoundaryChunkSpans, + iterateLosslessWordBoundaryChunkSpans, + normalizeTokenChunkSize, tokensToChars, } from '@/lib/chunkers/utils' @@ -15,6 +16,7 @@ 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 @@ -24,7 +26,7 @@ export class JsonYamlChunker { 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 } @@ -126,7 +128,8 @@ export class JsonYamlChunker { this.addBoundedChunk( chunks, budget, - this.buildBatchChunk(contextHeader, currentBatch, i - currentBatch.length, i - 1) + this.buildBatchChunk(contextHeader, currentBatch, i - currentBatch.length, i - 1), + 'preserve-range' ) currentBatch = [] currentTokens = 0 @@ -135,13 +138,24 @@ export class JsonYamlChunker { if (depth < MAX_DEPTH && typeof item === 'object' && item !== null) { this.chunkStructuredData(item, [...path, `[${i}]`], depth + 1, chunks, budget) } else { - this.chunkAsText(contextHeader + itemStr, budget, chunks) + 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) { this.addBoundedChunk( chunks, budget, - this.buildBatchChunk(contextHeader, currentBatch, i - currentBatch.length, i - 1) + this.buildBatchChunk(contextHeader, currentBatch, i - currentBatch.length, i - 1), + 'preserve-range' ) currentBatch = [item] currentTokens = itemTokens @@ -160,7 +174,8 @@ export class JsonYamlChunker { currentBatch, arr.length - currentBatch.length, arr.length - 1 - ) + ), + 'preserve-range' ) } } @@ -255,23 +270,31 @@ export class JsonYamlChunker { } } - private addBoundedChunk(chunks: Chunk[], budget: ChunkBudget, chunk: Chunk): void { + 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 iterateWordBoundaryChunkSpans( + for (const segment of iterateLosslessWordBoundaryChunkSpans( chunk.text, tokensToChars(this.chunkSize) )) { budget.add(chunks, { text: segment.text, tokenCount: estimateTokens(segment.text), - metadata: { - startIndex: chunk.metadata.startIndex + segment.startIndex, - endIndex: chunk.metadata.startIndex + segment.endIndex, - }, + metadata: + metadataMode === 'preserve-range' + ? chunk.metadata + : { + startIndex: chunk.metadata.startIndex + segment.startIndex, + endIndex: chunk.metadata.startIndex + segment.endIndex, + }, }) } } @@ -296,7 +319,10 @@ export class JsonYamlChunker { currentTokens = 0 } const lineStartIndex = startIndex - for (const segment of iterateWordBoundaryChunkSpans(line, tokensToChars(this.chunkSize))) { + for (const segment of iterateLosslessWordBoundaryChunkSpans( + line, + tokensToChars(this.chunkSize) + )) { budget.add(chunks, { text: segment.text, tokenCount: estimateTokens(segment.text), diff --git a/apps/sim/lib/chunkers/structured-data-chunker.test.ts b/apps/sim/lib/chunkers/structured-data-chunker.test.ts index e9f7aaa8bd6..7c92ae04059 100644 --- a/apps/sim/lib/chunkers/structured-data-chunker.test.ts +++ b/apps/sim/lib/chunkers/structured-data-chunker.test.ts @@ -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 @@ -141,8 +165,8 @@ Bob,25` }) it('splits an oversized header without repeating it into every row segment', async () => { - const header = `HEADER-${'h'.repeat(5_000)}-END` - const row = `ROW-${'r'.repeat(10_000)}-END` + 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, @@ -151,12 +175,37 @@ Bob,25` expect(chunks.length).toBeGreaterThan(1) expect(chunks.length).toBeLessThan(20) expect(chunks.every((chunk) => chunk.tokenCount <= 1024)).toBe(true) - expect(chunks.some((chunk) => chunk.text.includes('HEADER-'))).toBe(true) - expect(chunks.some((chunk) => chunk.text.includes('ROW-'))).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') diff --git a/apps/sim/lib/chunkers/structured-data-chunker.ts b/apps/sim/lib/chunkers/structured-data-chunker.ts index 0a18699b12c..59f2683617c 100644 --- a/apps/sim/lib/chunkers/structured-data-chunker.ts +++ b/apps/sim/lib/chunkers/structured-data-chunker.ts @@ -1,7 +1,11 @@ import { createLogger } from '@sim/logger' import { ChunkBudget } from '@/lib/chunkers/chunk-budget' import type { Chunk, StructuredDataOptions } from '@/lib/chunkers/types' -import { iterateLines, iterateWordBoundaryChunks } from '@/lib/chunkers/utils' +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 { @@ -23,6 +27,11 @@ export class StructuredDataChunker { content: string, options: StructuredDataOptions = {} ): Promise { + const targetChunkSize = normalizeTokenChunkSize( + options.chunkSize ?? DEFAULT_CONFIG.TARGET_CHUNK_SIZE, + 'Structured data chunk size' + ) + const chunks: Chunk[] = [] const sampleLines: string[] = [] for (const line of iterateLines(content)) { @@ -36,8 +45,6 @@ export class StructuredDataChunker { } const budget = new ChunkBudget(options.maxChunks) - const targetChunkSize = options.chunkSize ?? DEFAULT_CONFIG.TARGET_CHUNK_SIZE - const headerLine = options.headers?.join('\t') || sampleLines[0] const dataStartIndex = options.headers ? 0 : 1 @@ -84,22 +91,28 @@ export class StructuredDataChunker { ? `${options.sheetName}\n${headerLine}` : headerLine const headerRow = Math.max(0, dataStartIndex - 1) - for (const segment of iterateWordBoundaryChunks(headerContent, targetChunkSize * 3)) { - budget.add(chunks, StructuredDataChunker.createChunk(segment, headerRow, headerRow)) + for (const segment of iterateLosslessWordBoundaryChunkSpans( + headerContent, + targetChunkSize * 3 + )) { + budget.add( + chunks, + StructuredDataChunker.createChunk(segment.text, headerRow, headerRow) + ) } oversizedHeaderEmitted = true } - for (const segment of iterateWordBoundaryChunks(row, targetChunkSize * 3)) { - budget.add(chunks, StructuredDataChunker.createChunk(segment, i, i)) + 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 iterateWordBoundaryChunks(row, rowSegmentChars)) { + for (const segment of iterateLosslessWordBoundaryChunkSpans(row, rowSegmentChars)) { const segmentContent = StructuredDataChunker.formatChunk( headerLine, - [segment], + [segment.text], options.sheetName ) budget.add(chunks, StructuredDataChunker.createChunk(segmentContent, i, i)) diff --git a/apps/sim/lib/chunkers/utils.test.ts b/apps/sim/lib/chunkers/utils.test.ts index bc88bc0e46a..0d3861e42c8 100644 --- a/apps/sim/lib/chunkers/utils.test.ts +++ b/apps/sim/lib/chunkers/utils.test.ts @@ -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, diff --git a/apps/sim/lib/chunkers/utils.ts b/apps/sim/lib/chunkers/utils.ts index ccc6b066f8b..7672782c563 100644 --- a/apps/sim/lib/chunkers/utils.ts +++ b/apps/sim/lib/chunkers/utils.ts @@ -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') @@ -69,12 +84,33 @@ export interface WordBoundaryChunkSpan { endIndex: number } +/** Iterates bounded word-aware source slices without trimming or skipping characters. */ +export function* iterateLosslessWordBoundaryChunkSpans( + text: string, + chunkSizeChars: number +): Generator { + 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 { + assertPositiveSafeInteger(chunkSizeChars, 'Word-boundary chunk size') + let pos = 0 while (pos < text.length) { @@ -196,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, diff --git a/apps/sim/lib/core/security/redaction.test.ts b/apps/sim/lib/core/security/redaction.test.ts index 6552f153214..ac493b709ad 100644 --- a/apps/sim/lib/core/security/redaction.test.ts +++ b/apps/sim/lib/core/security/redaction.test.ts @@ -196,12 +196,80 @@ describe('redactSensitiveValues', () => { }) it.concurrent('redacts authorization schemes without exposing the credential suffix', () => { - expect(redactSensitiveValues('authorization=Bearer token123 scope=openid')).toBe( + expect(redactSensitiveValues('authorization=Bearer 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 = 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', () => { @@ -217,6 +285,54 @@ describe('redactSensitiveValues', () => { } }) + 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' @@ -243,6 +359,60 @@ describe('redactSensitiveValues', () => { } ) + 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' @@ -275,6 +445,15 @@ describe('redactSensitiveValues', () => { 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', () => { diff --git a/apps/sim/lib/core/security/redaction.ts b/apps/sim/lib/core/security/redaction.ts index 0eb3090bd73..af6ea2c5487 100644 --- a/apps/sim/lib/core/security/redaction.ts +++ b/apps/sim/lib/core/security/redaction.ts @@ -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,91 +85,311 @@ const SENSITIVE_VALUE_PATTERNS: Array<{ }, ] -const FORM_FIELD_MARKER_PATTERN = /\b([A-Za-z0-9_-]+)=/gi -const ENCODED_FORM_FIELD_MARKER_PATTERN = /\b([A-Za-z0-9_-]+)%3D/gi -const FORM_VALUE_DELIMITER_PATTERN = /&|\s+(?=[A-Za-z0-9_-]+(?:=|%3D))/gi -const ENCODED_FORM_VALUE_DELIMITER_PATTERN = /%26|&|\s+(?=[A-Za-z0-9_-]+(?:=|%3D))/gi +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 SensitiveValueSpan { +interface ActiveSensitiveFormField { start: number - end: 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 { const lowerKey = key.toLowerCase() if (BYPASS_REDACTION_KEYS.has(lowerKey)) return false return SENSITIVE_KEY_PATTERNS.some((pattern) => pattern.test(lowerKey)) } -function findFormValueEnd(delimiterPositions: number[], start: number): number { - let lower = 0 - let upper = delimiterPositions.length - while (lower < upper) { - const middle = Math.floor((lower + upper) / 2) - if (delimiterPositions[middle] < start) lower = middle + 1 - else upper = middle - } - return delimiterPositions[lower] +function getFormEncodingDepth(marker: string): number { + return marker.length === 1 ? 0 : (marker.length - 1) / 2 } -function collectSensitiveValueSpans( - value: string, - markerPattern: RegExp, - delimiterPositions: number[] -): SensitiveValueSpan[] { - const spans: SensitiveValueSpan[] = [] - for (const match of value.matchAll(markerPattern)) { - if (match.index === undefined || !isSensitiveKey(match[1])) continue - const start = match.index + match[0].length - const end = findFormValueEnd(delimiterPositions, start) - if (end > start) spans.push({ start, end }) - } - return spans +function normalizeFormKey(key: string): string { + return key.toLowerCase().replaceAll('_', '').replaceAll('-', '') } -function collectDelimiterPositions(value: string, pattern: RegExp): number[] { - const delimiterPositions: number[] = [] - for (const match of value.matchAll(pattern)) { - if (match.index !== undefined) delimiterPositions.push(match.index) +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 } + } } - delimiterPositions.push(value.length) - return delimiterPositions + return { value, complete: !ENCODED_FORM_KEY_COMPONENT_PATTERN.test(value) } } -function redactSensitiveFormFields(value: string): string { - const formDelimiterPositions = collectDelimiterPositions(value, FORM_VALUE_DELIMITER_PATTERN) - const encodedDelimiterPositions = collectDelimiterPositions( - value, - ENCODED_FORM_VALUE_DELIMITER_PATTERN +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 === '-' ) - const spans = [ - ...collectSensitiveValueSpans(value, FORM_FIELD_MARKER_PATTERN, formDelimiterPositions), - ...collectSensitiveValueSpans( - value, - ENCODED_FORM_FIELD_MARKER_PATTERN, - encodedDelimiterPositions - ), - ].sort((left, right) => left.start - right.start || right.end - left.end) - - if (spans.length === 0) return value - - const merged: SensitiveValueSpan[] = [] - for (const span of spans) { - const previous = merged.at(-1) - if (previous && span.start <= previous.end) { - previous.end = Math.max(previous.end, span.end) - } else { - merged.push({ ...span }) +} + +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 { + 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 - for (const span of merged) { - result += `${value.slice(cursor, span.start)}${REDACTED_MARKER}` - cursor = span.end + 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 } - return result + value.slice(cursor) + + 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) } /** @@ -190,10 +422,15 @@ export function redactKnownSensitiveValues(value: string, secrets: string[]): st ) for (const secret of orderedSecrets) { result = result.replaceAll(secret, REDACTED_MARKER) - const encodedVariants = new Set([ - encodeURIComponent(secret), - new URLSearchParams({ value: secret }).toString().slice('value='.length), - ]) + const encodedVariants = new Set() + 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, '\\$&') diff --git a/apps/sim/lib/knowledge/documents/utils.test.ts b/apps/sim/lib/knowledge/documents/utils.test.ts index 19a80cb042b..150f44af886 100644 --- a/apps/sim/lib/knowledge/documents/utils.test.ts +++ b/apps/sim/lib/knowledge/documents/utils.test.ts @@ -491,12 +491,12 @@ describe('fetchWithRetry rate-limit handling', () => { expect(error?.message.length).toBeLessThan(500) }) - it('redacts credentials echoed by a retryable provider error', async () => { - const secret = 'sk-provider-secret-value-1234567890' + it('omits a provider-controlled response body from the retry error', async () => { + const providerControlledMarker = 'provider-controlled-response-marker' globalThis.fetch = vi.fn().mockResolvedValue( - new Response(JSON.stringify({ authorization: `Bearer ${secret}` }), { + new Response(JSON.stringify({ diagnostic: providerControlledMarker }), { status: 522, - statusText: `Connection timed out: ${secret}`, + statusText: `Connection timed out: ${providerControlledMarker}`, }) ) @@ -510,34 +510,9 @@ describe('fetchWithRetry rate-limit handling', () => { ) expect(error?.message).toContain('[response body omitted]') - expect(error?.message).not.toContain(secret) + expect(error?.message).not.toContain(providerControlledMarker) }) - it.each(['api_key', 'authorization', 'client_secret', 'credential', 'private_key'])( - 'redacts a long structured %s before truncating the diagnostic', - async (sensitiveKey) => { - const secretPrefix = 'sensitive-value-prefix' - globalThis.fetch = vi.fn().mockResolvedValue( - new Response(JSON.stringify({ [sensitiveKey]: `${secretPrefix}${'x'.repeat(3000)}` }), { - status: 522, - statusText: 'Connection timed out', - }) - ) - - const error = await fetchWithRetry( - 'https://api.fireflies.ai/graphql', - {}, - { ...FAST_RETRY, maxRetries: 0 } - ).then( - () => undefined, - (caught) => caught as Error - ) - - expect(error?.message).toContain('[response body omitted]') - expect(error?.message).not.toContain(secretPrefix) - } - ) - it('does not retry an authorization 403 with quota remaining', async () => { const fetchMock = vi.fn().mockResolvedValue(response(403, { 'x-ratelimit-remaining': '4999' })) globalThis.fetch = fetchMock @@ -834,77 +809,15 @@ describe('secureFetchWithRetry', () => { expect(mockSecureFetchWithValidation).toHaveBeenCalledTimes(2) }) - it('redacts request credentials echoed by a retryable response', async () => { - const accessToken = 'bare-gitlab-token-that-must-not-escape' - mockSecureFetchWithValidation.mockResolvedValue( - new Response(`echo: ${accessToken}`, { status: 503 }) as never - ) - - const error = await secureFetchWithRetry( - 'https://gitlab.example.com/api/v4/projects', - { method: 'GET', headers: { 'PRIVATE-TOKEN': accessToken } }, - { ...FAST_RETRY, maxRetries: 0 } - ).then( - () => undefined, - (caught) => caught as Error - ) - - expect(error?.message).toContain('[response body omitted]') - expect(error?.message).not.toContain(accessToken) - }) - - it('redacts an echoed credential before truncating the diagnostic', async () => { - const accessToken = `opaque-${'x'.repeat(3000)}-tail` + it('omits a provider-controlled response body from the secure retry error', async () => { + const providerControlledMarker = 'provider-controlled-response-marker' mockSecureFetchWithValidation.mockResolvedValue( - new Response(accessToken, { status: 503 }) as never + new Response(providerControlledMarker, { status: 503 }) as never ) const error = await secureFetchWithRetry( 'https://gitlab.example.com/api/v4/projects', - { method: 'GET', headers: { 'PRIVATE-TOKEN': accessToken } }, - { ...FAST_RETRY, maxRetries: 0 } - ).then( - () => undefined, - (caught) => caught as Error - ) - - expect(error?.message).toContain('[response body omitted]') - expect(error?.message).not.toContain('opaque-xxxxxxxxxxxxxxxx') - }) - - it.each(['Bearer ', 'Bearer\t', ' Bearer '])( - 'redacts a bare credential separated from its scheme by whitespace: %j', - async (scheme) => { - const accessToken = 'whitespace-separated-token-that-must-not-escape' - mockSecureFetchWithValidation.mockResolvedValue( - new Response(`echo: ${accessToken}`, { status: 503 }) as never - ) - - const error = await secureFetchWithRetry( - 'https://example.com/api', - { method: 'GET', headers: { Authorization: `${scheme}${accessToken}` } }, - { ...FAST_RETRY, maxRetries: 0 } - ).then( - () => undefined, - (caught) => caught as Error - ) - - expect(error?.message).toContain('[response body omitted]') - expect(error?.message).not.toContain(accessToken) - } - ) - - it('redacts both Basic-auth fields using the first colon as the separator', async () => { - const username = 'basic-user-private' - const password = 'basic-password:with:colons' - const authorization = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}` - mockSecureFetchWithValidation.mockResolvedValue( - new Response(`echo: ${username} / ${password}`, { status: 503 }) as never - ) - - const error = await secureFetchWithRetry( - 'https://example.com/api', - { method: 'GET', headers: { Authorization: authorization } }, + { method: 'GET' }, { ...FAST_RETRY, maxRetries: 0 } ).then( () => undefined, @@ -912,7 +825,6 @@ describe('secureFetchWithRetry', () => { ) expect(error?.message).toContain('[response body omitted]') - expect(error?.message).not.toContain(username) - expect(error?.message).not.toContain(password) + expect(error?.message).not.toContain(providerControlledMarker) }) }) From ae4bf769a907919218e986a8fd208d9126b43379 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 25 Aug 2026 17:21:43 -0700 Subject: [PATCH 4/5] fix(knowledge): close bounded ingestion gaps --- .../connectors/route.test.ts | 39 +++ .../chunkers/structured-data-chunker.test.ts | 11 + .../lib/chunkers/structured-data-chunker.ts | 36 ++- apps/sim/lib/file-parsers/json-parser.test.ts | 45 +++ apps/sim/lib/file-parsers/json-parser.ts | 264 ++++++++++++------ .../knowledge/documents/document-processor.ts | 4 +- apps/sim/tools/mistral/parser.ts | 4 +- 7 files changed, 306 insertions(+), 97 deletions(-) create mode 100644 apps/sim/lib/file-parsers/json-parser.test.ts diff --git a/apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/connectors/route.test.ts b/apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/connectors/route.test.ts index 41de78d5dd1..48fb43c3d4f 100644 --- a/apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/connectors/route.test.ts +++ b/apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/connectors/route.test.ts @@ -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', { diff --git a/apps/sim/lib/chunkers/structured-data-chunker.test.ts b/apps/sim/lib/chunkers/structured-data-chunker.test.ts index 7c92ae04059..7dabc13b256 100644 --- a/apps/sim/lib/chunkers/structured-data-chunker.test.ts +++ b/apps/sim/lib/chunkers/structured-data-chunker.test.ts @@ -216,6 +216,17 @@ Bob,25` 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 diff --git a/apps/sim/lib/chunkers/structured-data-chunker.ts b/apps/sim/lib/chunkers/structured-data-chunker.ts index 59f2683617c..74522c251fe 100644 --- a/apps/sim/lib/chunkers/structured-data-chunker.ts +++ b/apps/sim/lib/chunkers/structured-data-chunker.ts @@ -13,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 = { @@ -57,8 +73,7 @@ export class StructuredDataChunker { ) let currentChunkRows: string[] = [] - let currentTokenEstimate = 0 - const headerTokens = estimateStructuredTokens(headerLine) + let currentRowsCharacters = 0 let chunkStartRow = dataStartIndex let oversizedHeaderEmitted = false @@ -68,7 +83,6 @@ export class StructuredDataChunker { const i = lineIndex lineIndex++ if (i < dataStartIndex) continue - const rowTokens = estimateStructuredTokens(row) const standaloneRow = StructuredDataChunker.formatChunk(headerLine, [row], options.sheetName) if (estimateStructuredTokens(standaloneRow) > targetChunkSize) { @@ -80,7 +94,7 @@ export class StructuredDataChunker { ) budget.add(chunks, StructuredDataChunker.createChunk(chunkContent, chunkStartRow, i - 1)) currentChunkRows = [] - currentTokenEstimate = 0 + currentRowsCharacters = 0 } const emptyRowOverhead = estimateStructuredTokens( StructuredDataChunker.formatChunk(headerLine, [''], options.sheetName) @@ -121,10 +135,12 @@ export class StructuredDataChunker { continue } - const projectedTokens = - currentTokenEstimate + - rowTokens + - (DEFAULT_CONFIG.INCLUDE_HEADERS_IN_EACH_CHUNK ? headerTokens : 0) + const projectedTokens = estimateFormattedChunkTokens( + headerLine, + currentRowsCharacters + row.length, + currentChunkRows.length + 1, + options.sheetName + ) const shouldCreateChunk = (projectedTokens > targetChunkSize && currentChunkRows.length > 0) || @@ -139,12 +155,12 @@ export class StructuredDataChunker { 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) { diff --git a/apps/sim/lib/file-parsers/json-parser.test.ts b/apps/sim/lib/file-parsers/json-parser.test.ts new file mode 100644 index 00000000000..fcc106706fb --- /dev/null +++ b/apps/sim/lib/file-parsers/json-parser.test.ts @@ -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 }) + }) +}) diff --git a/apps/sim/lib/file-parsers/json-parser.ts b/apps/sim/lib/file-parsers/json-parser.ts index 1108228d915..cd47fa9c5bd 100644 --- a/apps/sim/lib/file-parsers/json-parser.ts +++ b/apps/sim/lib/file-parsers/json-parser.ts @@ -3,69 +3,166 @@ 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 + 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 { - const fs = await import('fs/promises') - const content = await fs.readFile(filePath, 'utf-8') - - try { - // Parse to validate JSON - const jsonData = JSON.parse(content) +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 pretty-printed JSON for better readability - const formattedContent = JSON.stringify(jsonData, null, 2) +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 +} - // 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 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` + ) + } - return { - content: formattedContent, - metadata, - } - } catch (error) { - if (error instanceof FileParserError) throw error - if (!(error instanceof SyntaxError)) { - throw new FileParserError('runtime_failure', 'JSON processing failed unexpectedly', error) - } + budget.serializedUnits += + estimateJsonValueUnits(value, depth) + + (key === undefined ? 0 : serializedJsonStringLength(key) + 2) + if (budget.serializedUnits > MAX_JSON_SERIALIZED_UNITS) { throw new FileParserError( - 'invalid_format', - `Invalid JSON: ${getErrorMessage(error, 'Unknown error')}`, - error + '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 { - const content = buffer.toString('utf-8') +function assertJsonValueWithinLimits( + root: unknown, + budget: JsonComplexityBudget, + initialDepth = 0 +): number { + let maxDepth = initialDepth + const stack: JsonTraversalFrame[] = [] + + 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}` + ) + } - try { - const jsonData = JSON.parse(content) - const formattedContent = JSON.stringify(jsonData, null, 2) + maxDepth = Math.max(maxDepth, depth + 1) + if (Array.isArray(value)) { + stack.push({ value, depth, index: 0 }) + } else { + const record = value as Record + stack.push({ value: record, depth, index: 0, keys: Object.keys(record) }) + } + } - 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), + 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 } - return { - content: formattedContent, - metadata, + 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, + keys: isRecord ? Object.keys(jsonData as Record) : [], + itemCount: isArray ? jsonData.length : undefined, + depth, + }, + } +} + +function parseJsonContent(content: string): FileParseResult { + try { + return buildJsonResult(JSON.parse(content)) } catch (error) { if (error instanceof FileParserError) throw error if (!(error instanceof SyntaxError)) { @@ -79,69 +176,70 @@ export async function parseJSONBuffer(buffer: Buffer): Promise } } -/** - * Parse JSONL (JSON Lines) files — one JSON object per line - */ -export async function parseJSONL(filePath: string): Promise { +/** Parse a JSON file. */ +export async function parseJSON(filePath: string): Promise { const fs = await import('fs/promises') - const content = await fs.readFile(filePath, 'utf-8') - return parseJSONLContent(content) + return parseJsonContent(await fs.readFile(filePath, 'utf-8')) } -/** - * Parse JSONL from buffer - */ -export async function parseJSONLBuffer(buffer: Buffer): Promise { - const content = buffer.toString('utf-8') - return parseJSONLContent(content) +/** Parse JSON from a buffer. */ +export async function parseJSONBuffer(buffer: Buffer): Promise { + 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 parseJSONLContent(content: string): FileParseResult { - const lines = content.split('\n').filter((line) => line.trim()) +function parseJsonLinesContent(content: string): FileParseResult { const items: unknown[] = [] + const budget = { nodes: 0, serializedUnits: 0 } + let depth = assertJsonValueWithinLimits([], budget) - for (const line of lines) { + for (const { line, lineNumber } of iterateJsonLines(content)) { + let item: unknown try { - items.push(JSON.parse(line)) + item = JSON.parse(line) } catch (error) { throw new FileParserError( 'invalid_format', - `Invalid JSONL: failed to parse line: ${line.slice(0, 100)}`, + `Invalid JSONL on line ${lineNumber}: ${line.slice(0, 100)}`, error ) } + depth = Math.max(depth, assertJsonValueWithinLimits(item, budget, 1)) + items.push(item) } - const formattedContent = JSON.stringify(items, null, 2) - return { - content: formattedContent, + content: JSON.stringify(items, null, 2), metadata: { type: 'json', isArray: true, keys: [], itemCount: items.length, - depth: items.length > 0 ? 1 + getJsonDepth(items[0]) : 1, + depth, }, } } -/** - * Calculate the depth of a JSON object - */ -function getJsonDepth(value: unknown, depth = 0): number { - if (value === null || typeof value !== 'object') return depth - if (depth >= MAX_JSON_DEPTH) { - throw new FileParserError( - 'complexity_limit', - `JSON document exceeds the maximum nesting depth of ${MAX_JSON_DEPTH}` - ) - } +/** Parse a JSON Lines file. */ +export async function parseJSONL(filePath: string): Promise { + const fs = await import('fs/promises') + return parseJsonLinesContent(await fs.readFile(filePath, 'utf-8')) +} - let maxDepth = depth + 1 - const children = Array.isArray(value) ? value : Object.values(value as Record) - for (const child of children) { - maxDepth = Math.max(maxDepth, getJsonDepth(child, depth + 1)) - } - return maxDepth +/** Parse JSON Lines from a buffer. */ +export async function parseJSONLBuffer(buffer: Buffer): Promise { + return parseJsonLinesContent(buffer.toString('utf-8')) } diff --git a/apps/sim/lib/knowledge/documents/document-processor.ts b/apps/sim/lib/knowledge/documents/document-processor.ts index 2096de0a199..bba04d4bc2b 100644 --- a/apps/sim/lib/knowledge/documents/document-processor.ts +++ b/apps/sim/lib/knowledge/documents/document-processor.ts @@ -1221,9 +1221,9 @@ async function parseDataURI( filename: string, mimeType: string ): Promise { - const { buffer, mediaType } = decodeDataUriWithinLimit(fileUrl, MAX_FILE_SIZE) + const { buffer } = decodeDataUriWithinLimit(fileUrl, MAX_FILE_SIZE) const extension = resolveParserExtension(filename, mimeType, 'txt') - logger.info('Parsing bounded data URI', { bytes: buffer.length, mediaType, extension }) + logger.info('Parsing bounded data URI', { bytes: buffer.length, extension }) return parseBuffer(buffer, extension) } diff --git a/apps/sim/tools/mistral/parser.ts b/apps/sim/tools/mistral/parser.ts index 9bcca4b0ea6..9b47f843a47 100644 --- a/apps/sim/tools/mistral/parser.ts +++ b/apps/sim/tools/mistral/parser.ts @@ -287,8 +287,8 @@ export const mistralParserTool: ToolConfig Date: Tue, 25 Aug 2026 17:37:27 -0700 Subject: [PATCH 5/5] fix(files): preserve parser complexity limits --- apps/sim/app/api/files/parse/route.test.ts | 25 ++++++++++++++++++++++ apps/sim/app/api/files/parse/route.ts | 10 ++++----- 2 files changed, 29 insertions(+), 6 deletions(-) diff --git a/apps/sim/app/api/files/parse/route.test.ts b/apps/sim/app/api/files/parse/route.test.ts index ca6eac2dbfd..26c01caa2f5 100644 --- a/apps/sim/app/api/files/parse/route.test.ts +++ b/apps/sim/app/api/files/parse/route.test.ts @@ -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, diff --git a/apps/sim/app/api/files/parse/route.ts b/apps/sim/app/api/files/parse/route.ts index a844c9377c7..f86218641e5 100644 --- a/apps/sim/app/api/files/parse/route.ts +++ b/apps/sim/app/api/files/parse/route.ts @@ -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) }