From 896eee3758011c2d898ec23ffaa9a1da7b405bc1 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Thu, 21 May 2026 02:52:34 -0700 Subject: [PATCH 1/7] fix(table): derive typewriter slice from elapsed time (no full-text flash) (#4694) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reveal used a lagging nullable `revealed` state with a `revealed ?? kind.text` fallback in the caller. Under React 18 concurrent rendering a committed render could observe `revealed === null` while `text` was the full value, so the fallback painted the entire string for one frame before the type-on — an intermittent flash, reproducible on a large Run-all (verified in-browser: 60+ cells flashing). Derive the revealed slice from `text` + elapsed time during render instead of holding it in state. For a non-null value the result is never `null` and never the full string on the frame `text` changes (elapsed ≈ 0 → 0 chars), so the fallback can't fire. `prevText` is tracked in state (not a ref) so a discarded render rolls it back and the change is re-detected on the committed render. Verified via DOM MutationObserver: 0 flashes across 213 animated cells. --- .../table-grid/cells/cell-render.tsx | 58 +++++++++---------- 1 file changed, 28 insertions(+), 30 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-render.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-render.tsx index 48cd644480..27ff1f2ae4 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-render.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-render.tsx @@ -291,30 +291,25 @@ function Wrap({ isEditing, children }: { isEditing: boolean; children: React.Rea const TYPEWRITER_MS_PER_CHAR = 15 /** - * Reveals `text` character-by-character whenever it changes after the first - * render. Initial render (page hydration or virtualization remount) shows the - * value statically — animation fires only for subsequent updates, which in - * practice means SSE-driven workflow completions arriving via - * `useTableEventStream → applyCell()`. - * - * rAF-driven (not `setInterval`) so concurrent reveals batch into one - * render/paint per frame instead of O(cells) uncoordinated reflows; reveal - * length is elapsed-time based so dropped frames catch up rather than slow. + * Reveals `text` character-by-character when it changes after the first render; + * the initial render (mount / scroll-in) shows it statically. The slice is + * derived from elapsed time during render rather than held in state, so it is + * never `null` and never the full string on the frame `text` changes — which is + * what prevents the caller's `?? kind.text` fallback from flashing the whole + * value for a frame. `prevText` is state (not a ref) so a discarded render rolls + * it back and re-detects the change on the committed render. */ function useTypewriter(text: string | null): string | null { - const [revealed, setRevealed] = useState(text) - const prevTextRef = useRef(text) + const [prevText, setPrevText] = useState(text) + const [, forceFrame] = useState(0) const mountedRef = useRef(false) - const animateRef = useRef(false) - - // Reset synchronously during render when `text` changes (not on first mount) - // so no frame ever shows the full new value before the animation begins — - // an effect-based reset lands one frame late and flashes the whole text. - if (prevTextRef.current !== text) { - prevTextRef.current = text - const animate = mountedRef.current && text !== null && text.length > 0 - animateRef.current = animate - setRevealed(animate ? '' : text) + // Reveal-clock start; 0 = show statically (mount / cleared / empty). + const startRef = useRef(0) + + if (prevText !== text) { + setPrevText(text) + startRef.current = + mountedRef.current && text !== null && text.length > 0 ? performance.now() : 0 } useEffect(() => { @@ -322,19 +317,22 @@ function useTypewriter(text: string | null): string | null { }, []) useEffect(() => { - if (!animateRef.current) return - animateRef.current = false - const full = text as string - const start = performance.now() + if (startRef.current === 0 || text === null) return let raf = 0 - const tick = (now: number) => { - const chars = Math.min(full.length, Math.floor((now - start) / TYPEWRITER_MS_PER_CHAR)) - setRevealed(full.slice(0, chars)) - if (chars < full.length) raf = requestAnimationFrame(tick) + const tick = () => { + const chars = Math.floor((performance.now() - startRef.current) / TYPEWRITER_MS_PER_CHAR) + forceFrame((f) => f + 1) + if (chars < text.length) raf = requestAnimationFrame(tick) } raf = requestAnimationFrame(tick) return () => cancelAnimationFrame(raf) }, [text]) - return revealed + if (text === null) return null + if (startRef.current === 0) return text + const chars = Math.min( + text.length, + Math.floor((performance.now() - startRef.current) / TYPEWRITER_MS_PER_CHAR) + ) + return text.slice(0, chars) } From d892165337d41e023770e508b32694bfb67711b3 Mon Sep 17 00:00:00 2001 From: Waleed Date: Thu, 21 May 2026 10:50:35 -0700 Subject: [PATCH 2/7] fix(copilot): default SIM_AGENT_API_URL to www.copilot.sim.ai to avoid redirect path drop (#4700) --- apps/sim/lib/copilot/constants.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/sim/lib/copilot/constants.ts b/apps/sim/lib/copilot/constants.ts index c811831392..ff621d199a 100644 --- a/apps/sim/lib/copilot/constants.ts +++ b/apps/sim/lib/copilot/constants.ts @@ -1,6 +1,6 @@ import { env } from '@/lib/core/config/env' -export const SIM_AGENT_API_URL_DEFAULT = 'https://copilot.sim.ai' +export const SIM_AGENT_API_URL_DEFAULT = 'https://www.copilot.sim.ai' export const SIM_AGENT_VERSION = '3.0.0' /** Resolved copilot backend URL — reads from env with fallback to default. */ From 89695de81d4bd8b0fc7a9e7565c4fd46354a3088 Mon Sep 17 00:00:00 2001 From: Waleed Date: Thu, 21 May 2026 11:10:09 -0700 Subject: [PATCH 3/7] fix(mcp): cache result of discoverServerTools to prevent post-OAuth refetch storm (#4701) * fix(mcp): write per-server cache from discoverServerTools to prevent post-OAuth refetch storm * improvement(mcp): update server status from discoverServerTools + cap list-tools timeout at 30s --- apps/sim/app/api/mcp/oauth/callback/route.ts | 3 ++- apps/sim/lib/mcp/client.test.ts | 1 + apps/sim/lib/mcp/client.ts | 5 +++- apps/sim/lib/mcp/service.test.ts | 27 +++++++++++++++++--- apps/sim/lib/mcp/service.ts | 11 ++++++++ apps/sim/lib/mcp/utils.ts | 2 ++ 6 files changed, 44 insertions(+), 5 deletions(-) diff --git a/apps/sim/app/api/mcp/oauth/callback/route.ts b/apps/sim/app/api/mcp/oauth/callback/route.ts index 721675dac9..15b115c0b7 100644 --- a/apps/sim/app/api/mcp/oauth/callback/route.ts +++ b/apps/sim/app/api/mcp/oauth/callback/route.ts @@ -167,7 +167,8 @@ export const GET = withRouteHandler(async (request: NextRequest) => { } try { - await mcpService.clearCache(server.workspaceId) + // discoverServerTools writes the result to this server's cache so the UI's + // immediate refetch hits it instead of re-fetching live. await mcpService.discoverServerTools(session.user.id, server.id, server.workspaceId) } catch (e) { logger.warn('Post-auth tools refresh failed', toError(e).message) diff --git a/apps/sim/lib/mcp/client.test.ts b/apps/sim/lib/mcp/client.test.ts index 405b66610e..8f6279a2d1 100644 --- a/apps/sim/lib/mcp/client.test.ts +++ b/apps/sim/lib/mcp/client.test.ts @@ -37,6 +37,7 @@ vi.mock('@modelcontextprotocol/sdk/types.js', () => ({ vi.mock('@/lib/core/execution-limits', () => ({ getMaxExecutionTimeout: vi.fn().mockReturnValue(30000), + DEFAULT_EXECUTION_TIMEOUT_MS: 30000, })) import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js' diff --git a/apps/sim/lib/mcp/client.ts b/apps/sim/lib/mcp/client.ts index 819afc5660..9f3c36d00a 100644 --- a/apps/sim/lib/mcp/client.ts +++ b/apps/sim/lib/mcp/client.ts @@ -36,6 +36,7 @@ import { type McpToolsChangedCallback, type McpVersionInfo, } from '@/lib/mcp/types' +import { MCP_CLIENT_CONSTANTS } from '@/lib/mcp/utils' const logger = createLogger('McpClient') @@ -167,7 +168,9 @@ export class McpClient { } try { - const result: ListToolsResult = await this.client.listTools() + const result: ListToolsResult = await this.client.listTools(undefined, { + timeout: MCP_CLIENT_CONSTANTS.LIST_TOOLS_TIMEOUT_MS, + }) if (!result.tools || !Array.isArray(result.tools)) { logger.warn(`Invalid tools response from server ${this.config.name}:`, result) diff --git a/apps/sim/lib/mcp/service.test.ts b/apps/sim/lib/mcp/service.test.ts index 539ee38457..42d111640d 100644 --- a/apps/sim/lib/mcp/service.test.ts +++ b/apps/sim/lib/mcp/service.test.ts @@ -38,13 +38,20 @@ const { }) vi.mock('@sim/db', () => { + // `where(...)` resolves to the workspace's rows AND exposes `.limit()` for + // chains like `getServerConfig` that do `select().from().where().limit(1)`. + const where = (...args: unknown[]) => { + const rowsPromise = Promise.resolve(mockGetWorkspaceServersRows(...args)) + const thenable = Object.assign(rowsPromise, { + limit: (n: number) => rowsPromise.then((rows) => rows.slice(0, n)), + }) + return thenable + } const setter = vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(undefined) }) return { db: { select: vi.fn().mockReturnValue({ - from: vi.fn().mockReturnValue({ - where: (...args: unknown[]) => mockGetWorkspaceServersRows(...args), - }), + from: vi.fn().mockReturnValue({ where }), }), update: vi.fn().mockReturnValue({ set: setter }), insert: vi.fn(), @@ -238,4 +245,18 @@ describe('McpService.discoverTools per-server caching', () => { expect(second.map((t) => t.name)).toEqual(['a-other']) expect(mockListTools).toHaveBeenCalledTimes(2) }) + + it('discoverServerTools primes the per-server cache for follow-up discoverTools', async () => { + mockGetWorkspaceServersRows.mockResolvedValue([dbRow('mcp-a', 'A')]) + mockListTools.mockResolvedValueOnce([tool('a1', 'mcp-a')]) + + const tools = await mcpService.discoverServerTools(USER_ID, 'mcp-a', WORKSPACE_ID) + expect(tools.map((t) => t.name)).toEqual(['a1']) + expect(mockListTools).toHaveBeenCalledTimes(1) + + mockListTools.mockClear() + const second = await mcpService.discoverTools(USER_ID, WORKSPACE_ID) + expect(second.map((t) => t.name)).toEqual(['a1']) + expect(mockListTools).not.toHaveBeenCalled() + }) }) diff --git a/apps/sim/lib/mcp/service.ts b/apps/sim/lib/mcp/service.ts index f3ca6683ae..b024c17930 100644 --- a/apps/sim/lib/mcp/service.ts +++ b/apps/sim/lib/mcp/service.ts @@ -580,6 +580,17 @@ class McpService { try { const tools = await client.listTools() logger.info(`[${requestId}] Discovered ${tools.length} tools from server ${config.name}`) + // Prime the per-server cache and reflect the successful connection on + // the row so the UI doesn't keep showing "Connect with OAuth" or stale + // disconnected/error state. + await Promise.allSettled([ + this.cacheAdapter + .set(serverCacheKey(workspaceId, serverId), tools, this.cacheTimeout) + .catch((err) => + logger.warn(`[${requestId}] Cache write failed for ${config.name}:`, err) + ), + this.updateServerStatus(serverId, workspaceId, true, undefined, tools.length), + ]) return tools } finally { await client.disconnect() diff --git a/apps/sim/lib/mcp/utils.ts b/apps/sim/lib/mcp/utils.ts index 1fc231c7c6..af16621567 100644 --- a/apps/sim/lib/mcp/utils.ts +++ b/apps/sim/lib/mcp/utils.ts @@ -46,6 +46,8 @@ export function sanitizeHeaders( export const MCP_CLIENT_CONSTANTS = { CLIENT_TIMEOUT: DEFAULT_EXECUTION_TIMEOUT_MS, AUTO_REFRESH_INTERVAL: 5 * 60 * 1000, + // Cap metadata calls so a slow upstream can't hang the UI for 60s+. + LIST_TOOLS_TIMEOUT_MS: 30_000, } as const /** From d7ed3c24c0d52a5431193794a52764ebfe6acc96 Mon Sep 17 00:00:00 2001 From: mini Date: Fri, 22 May 2026 06:27:42 +0900 Subject: [PATCH 4/7] fix(sidebar): pass showDelete to hide delete menu for non-admin members (#4697) The ContextMenu component already has a showDelete prop with conditional rendering, but workflow-item and folder-item never pass it, leaving it at the default value of true. This causes write members to see an active Delete option that always fails with 403, since the DELETE API requires admin permission. Pass showDelete={userPermissions.canAdmin} from both workflow-item and folder-item so that non-admin users no longer see the Delete menu. Simplify disableDelete to only check canDeleteSelection and effectiveLocked, since permission gating is now handled by showDelete. --- .../workflow-list/components/folder-item/folder-item.tsx | 3 ++- .../workflow-list/components/workflow-item/workflow-item.tsx | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/folder-item/folder-item.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/folder-item/folder-item.tsx index 6b81862db0..f69b72c465 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/folder-item/folder-item.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/folder-item/folder-item.tsx @@ -612,7 +612,8 @@ export function FolderItem({ !userPermissions.canEdit || isDuplicatingSelection || !hasExportableContent } disableExport={!userPermissions.canEdit || isExporting || !hasExportableContent} - disableDelete={!userPermissions.canEdit || effectiveLocked || !canDeleteSelection} + showDelete={userPermissions.canAdmin} + disableDelete={effectiveLocked || !canDeleteSelection} onToggleLock={handleToggleLock} showLock={!isMixedSelection && selectedFolders.size <= 1} disableLock={!userPermissions.canAdmin || inheritedFolderLocked} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/workflow-item/workflow-item.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/workflow-item/workflow-item.tsx index 1d8eb73f85..991a69868e 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/workflow-item/workflow-item.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/workflow-item/workflow-item.tsx @@ -524,7 +524,8 @@ export function WorkflowItem({ disableDuplicate={!userPermissions.canEdit || isDuplicatingSelection} disableExport={!userPermissions.canEdit} disableColorChange={!userPermissions.canEdit || effectiveLocked} - disableDelete={!userPermissions.canEdit || !canDeleteSelection || effectiveLocked} + showDelete={userPermissions.canAdmin} + disableDelete={!canDeleteSelection || effectiveLocked} onToggleLock={handleToggleLock} showLock={!isMixedSelection && selectedWorkflows.size <= 1} disableLock={!userPermissions.canAdmin || inheritedFolderLocked} From 3f7698c66bf7c3969f2d3617f5a5fdf8db790993 Mon Sep 17 00:00:00 2001 From: Waleed Date: Thu, 21 May 2026 15:25:31 -0700 Subject: [PATCH 5/7] perf(db): reduce read/write fanout across hot paths (#4704) * perf(db): reduce read/write fanout across hot paths * fix(templates): include name/workflowId/status/tags in DELETE projection * fix(db): address review feedback (joinedAt backfill, mcp delete error, chatDeploy projection, sql newline) * fix(webhooks): include NULL failedCount in markWebhookSuccess guard --- apps/sim/app/api/templates/[id]/route.ts | 12 +- apps/sim/app/api/users/me/settings/route.ts | 19 +- .../admin/organizations/[id]/billing/route.ts | 6 +- .../app/api/v1/admin/organizations/route.ts | 17 +- apps/sim/app/api/v1/admin/types.ts | 33 +- apps/sim/app/api/v1/admin/workflows/route.ts | 20 +- .../admin/workspaces/[id]/workflows/route.ts | 15 +- .../v1/tables/[tableId]/rows/[rowId]/route.ts | 2 +- apps/sim/app/api/webhooks/[id]/route.ts | 20 +- .../app/workspace/[workspaceId]/logs/logs.tsx | 8 +- .../[tableId]/hooks/use-table-event-stream.ts | 20 +- .../components/tool-input/tool-input.tsx | 2 +- .../hooks/use-child-workflow.ts | 3 +- .../w/[workflowId]/hooks/use-wand.ts | 2 +- .../hooks/use-workflow-execution.ts | 4 +- .../usage-indicator/usage-indicator.tsx | 4 +- apps/sim/hooks/queries/subscription.ts | 4 +- apps/sim/lib/copilot/async-runs/repository.ts | 6 +- .../tools/handlers/deployment/manage.ts | 18 +- apps/sim/lib/credentials/environment.ts | 142 +- .../orchestration/workflow-mcp-lifecycle.ts | 40 +- apps/sim/lib/templates/permissions.ts | 5 +- apps/sim/lib/webhooks/polling/utils.ts | 6 +- .../workflows/orchestration/chat-deploy.ts | 11 +- packages/db/migrations/0211_breezy_cloak.sql | 4 + .../db/migrations/meta/0211_snapshot.json | 16710 ++++++++++++++++ packages/db/migrations/meta/_journal.json | 7 + packages/db/schema.ts | 15 + 28 files changed, 17014 insertions(+), 141 deletions(-) create mode 100644 packages/db/migrations/0211_breezy_cloak.sql create mode 100644 packages/db/migrations/meta/0211_snapshot.json diff --git a/apps/sim/app/api/templates/[id]/route.ts b/apps/sim/app/api/templates/[id]/route.ts index bb2e4a48c9..8f4c0e367c 100644 --- a/apps/sim/app/api/templates/[id]/route.ts +++ b/apps/sim/app/api/templates/[id]/route.ts @@ -311,7 +311,17 @@ export const DELETE = withRouteHandler( return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - const existing = await db.select().from(templates).where(eq(templates.id, id)).limit(1) + const existing = await db + .select({ + name: templates.name, + workflowId: templates.workflowId, + creatorId: templates.creatorId, + status: templates.status, + tags: templates.tags, + }) + .from(templates) + .where(eq(templates.id, id)) + .limit(1) if (existing.length === 0) { logger.warn(`[${requestId}] Template not found for delete: ${id}`) return NextResponse.json({ error: 'Template not found' }, { status: 404 }) diff --git a/apps/sim/app/api/users/me/settings/route.ts b/apps/sim/app/api/users/me/settings/route.ts index 0ad9cfa186..47e0ac452e 100644 --- a/apps/sim/app/api/users/me/settings/route.ts +++ b/apps/sim/app/api/users/me/settings/route.ts @@ -39,7 +39,24 @@ export const GET = withRouteHandler(async () => { } const userId = session.user.id - const result = await db.select().from(settings).where(eq(settings.userId, userId)).limit(1) + const result = await db + .select({ + theme: settings.theme, + autoConnect: settings.autoConnect, + telemetryEnabled: settings.telemetryEnabled, + emailPreferences: settings.emailPreferences, + billingUsageNotificationsEnabled: settings.billingUsageNotificationsEnabled, + showTrainingControls: settings.showTrainingControls, + superUserModeEnabled: settings.superUserModeEnabled, + mothershipEnvironment: settings.mothershipEnvironment, + errorNotificationsEnabled: settings.errorNotificationsEnabled, + snapToGridSize: settings.snapToGridSize, + showActionBar: settings.showActionBar, + lastActiveWorkspaceId: settings.lastActiveWorkspaceId, + }) + .from(settings) + .where(eq(settings.userId, userId)) + .limit(1) if (!result.length) { return NextResponse.json({ data: defaultSettings }, { status: 200 }) diff --git a/apps/sim/app/api/v1/admin/organizations/[id]/billing/route.ts b/apps/sim/app/api/v1/admin/organizations/[id]/billing/route.ts index df91e08bf2..9dd083a30a 100644 --- a/apps/sim/app/api/v1/admin/organizations/[id]/billing/route.ts +++ b/apps/sim/app/api/v1/admin/organizations/[id]/billing/route.ts @@ -55,7 +55,11 @@ export const GET = withRouteHandler( try { if (!isBillingEnabled) { const [[orgData], [memberCount]] = await Promise.all([ - db.select().from(organization).where(eq(organization.id, organizationId)).limit(1), + db + .select({ id: organization.id, name: organization.name }) + .from(organization) + .where(eq(organization.id, organizationId)) + .limit(1), db .select({ count: count() }) .from(member) diff --git a/apps/sim/app/api/v1/admin/organizations/route.ts b/apps/sim/app/api/v1/admin/organizations/route.ts index 9e3b8b5983..07ee15890e 100644 --- a/apps/sim/app/api/v1/admin/organizations/route.ts +++ b/apps/sim/app/api/v1/admin/organizations/route.ts @@ -71,7 +71,22 @@ export const GET = withRouteHandler( try { const [countResult, organizations] = await Promise.all([ db.select({ total: count() }).from(organization), - db.select().from(organization).orderBy(organization.name).limit(limit).offset(offset), + db + .select({ + id: organization.id, + name: organization.name, + slug: organization.slug, + logo: organization.logo, + orgUsageLimit: organization.orgUsageLimit, + storageUsedBytes: organization.storageUsedBytes, + departedMemberUsage: organization.departedMemberUsage, + createdAt: organization.createdAt, + updatedAt: organization.updatedAt, + }) + .from(organization) + .orderBy(organization.name) + .limit(limit) + .offset(offset), ]) const total = countResult[0].total diff --git a/apps/sim/app/api/v1/admin/types.ts b/apps/sim/app/api/v1/admin/types.ts index 525f22671b..3cdbfc46d2 100644 --- a/apps/sim/app/api/v1/admin/types.ts +++ b/apps/sim/app/api/v1/admin/types.ts @@ -198,7 +198,23 @@ export interface AdminWorkflowDetail extends AdminWorkflow { edgeCount: number } -export function toAdminWorkflow(dbWorkflow: DbWorkflow): AdminWorkflow { +export type AdminWorkflowSource = Pick< + DbWorkflow, + | 'id' + | 'name' + | 'description' + | 'color' + | 'workspaceId' + | 'folderId' + | 'isDeployed' + | 'deployedAt' + | 'runCount' + | 'lastRunAt' + | 'createdAt' + | 'updatedAt' +> + +export function toAdminWorkflow(dbWorkflow: AdminWorkflowSource): AdminWorkflow { return { id: dbWorkflow.id, name: dbWorkflow.name, @@ -443,7 +459,20 @@ export interface AdminOrganizationDetail extends AdminOrganization { subscription: AdminSubscription | null } -export function toAdminOrganization(dbOrg: DbOrganization): AdminOrganization { +export type AdminOrganizationSource = Pick< + DbOrganization, + | 'id' + | 'name' + | 'slug' + | 'logo' + | 'orgUsageLimit' + | 'storageUsedBytes' + | 'departedMemberUsage' + | 'createdAt' + | 'updatedAt' +> + +export function toAdminOrganization(dbOrg: AdminOrganizationSource): AdminOrganization { return { id: dbOrg.id, name: dbOrg.name, diff --git a/apps/sim/app/api/v1/admin/workflows/route.ts b/apps/sim/app/api/v1/admin/workflows/route.ts index 1b8477df16..0a85dd7834 100644 --- a/apps/sim/app/api/v1/admin/workflows/route.ts +++ b/apps/sim/app/api/v1/admin/workflows/route.ts @@ -33,7 +33,25 @@ export const GET = withRouteHandler( try { const [countResult, workflows] = await Promise.all([ db.select({ total: count() }).from(workflow), - db.select().from(workflow).orderBy(workflow.name).limit(limit).offset(offset), + db + .select({ + id: workflow.id, + name: workflow.name, + description: workflow.description, + color: workflow.color, + workspaceId: workflow.workspaceId, + folderId: workflow.folderId, + isDeployed: workflow.isDeployed, + deployedAt: workflow.deployedAt, + runCount: workflow.runCount, + lastRunAt: workflow.lastRunAt, + createdAt: workflow.createdAt, + updatedAt: workflow.updatedAt, + }) + .from(workflow) + .orderBy(workflow.name) + .limit(limit) + .offset(offset), ]) const total = countResult[0].total diff --git a/apps/sim/app/api/v1/admin/workspaces/[id]/workflows/route.ts b/apps/sim/app/api/v1/admin/workspaces/[id]/workflows/route.ts index 1184b781ed..8a841ee6ba 100644 --- a/apps/sim/app/api/v1/admin/workspaces/[id]/workflows/route.ts +++ b/apps/sim/app/api/v1/admin/workspaces/[id]/workflows/route.ts @@ -63,7 +63,20 @@ export const GET = withRouteHandler( .from(workflow) .where(and(eq(workflow.workspaceId, workspaceId), isNull(workflow.archivedAt))), db - .select() + .select({ + id: workflow.id, + name: workflow.name, + description: workflow.description, + color: workflow.color, + workspaceId: workflow.workspaceId, + folderId: workflow.folderId, + isDeployed: workflow.isDeployed, + deployedAt: workflow.deployedAt, + runCount: workflow.runCount, + lastRunAt: workflow.lastRunAt, + createdAt: workflow.createdAt, + updatedAt: workflow.updatedAt, + }) .from(workflow) .where(and(eq(workflow.workspaceId, workspaceId), isNull(workflow.archivedAt))) .orderBy(workflow.name) diff --git a/apps/sim/app/api/v1/tables/[tableId]/rows/[rowId]/route.ts b/apps/sim/app/api/v1/tables/[tableId]/rows/[rowId]/route.ts index 96e2cf323c..4724b39b24 100644 --- a/apps/sim/app/api/v1/tables/[tableId]/rows/[rowId]/route.ts +++ b/apps/sim/app/api/v1/tables/[tableId]/rows/[rowId]/route.ts @@ -230,7 +230,7 @@ export const DELETE = withRouteHandler(async (request: NextRequest, context: Row eq(userTableRows.workspaceId, workspaceId) ) ) - .returning() + .returning({ id: userTableRows.id }) if (!deletedRow) { return NextResponse.json({ error: 'Row not found' }, { status: 404 }) diff --git a/apps/sim/app/api/webhooks/[id]/route.ts b/apps/sim/app/api/webhooks/[id]/route.ts index ab65529dfa..ea79ee38f6 100644 --- a/apps/sim/app/api/webhooks/[id]/route.ts +++ b/apps/sim/app/api/webhooks/[id]/route.ts @@ -137,13 +137,23 @@ export const PATCH = withRouteHandler( } await assertWorkflowMutable(webhookData.workflow.id) + const setClause: Partial = {} + if (isActive !== undefined && isActive !== webhooks[0].webhook.isActive) { + setClause.isActive = isActive + } + if (failedCount !== undefined && failedCount !== webhooks[0].webhook.failedCount) { + setClause.failedCount = failedCount + } + + if (Object.keys(setClause).length === 0) { + logger.info(`[${requestId}] No-op webhook PATCH (no field changes): ${id}`) + return NextResponse.json({ webhook: webhooks[0].webhook }, { status: 200 }) + } + + setClause.updatedAt = new Date() const updatedWebhook = await db .update(webhook) - .set({ - isActive: isActive !== undefined ? isActive : webhooks[0].webhook.isActive, - failedCount: failedCount !== undefined ? failedCount : webhooks[0].webhook.failedCount, - updatedAt: new Date(), - }) + .set(setClause) .where(eq(webhook.id, id)) .returning() diff --git a/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx b/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx index 7ddf9eec8f..aaa32744da 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx @@ -103,6 +103,8 @@ import { const LOGS_PER_PAGE = 50 as const const SORTABLE_COLUMNS: readonly LogSortBy[] = ['date', 'duration', 'cost', 'status'] as const const REFRESH_SPINNER_DURATION_MS = 1000 as const +const LIVE_REFRESH_INTERVAL_MS = 10_000 as const +const ACTIVE_RUN_DETAIL_REFRESH_MS = 3_000 as const const LOG_COLUMNS: ResourceColumn[] = [ { id: 'workflow', header: 'Workflow' }, @@ -317,7 +319,7 @@ export default function Logs() { (query: { state: { data?: WorkflowLogDetail } }) => { if (!isLive) return false const status = query.state.data?.status - return status === 'running' || status === 'pending' ? 3000 : false + return status === 'running' || status === 'pending' ? ACTIVE_RUN_DETAIL_REFRESH_MS : false }, [isLive] ) @@ -365,7 +367,7 @@ export default function Logs() { ) const logsQuery = useLogsList(workspaceId, logFilters, { - refetchInterval: isLive ? 3000 : false, + refetchInterval: isLive ? LIVE_REFRESH_INTERVAL_MS : false, }) const dashboardFilters = useMemo( @@ -383,7 +385,7 @@ export default function Logs() { ) const dashboardStatsQuery = useDashboardStats(workspaceId, dashboardFilters, { - refetchInterval: isLive ? 3000 : false, + refetchInterval: isLive ? LIVE_REFRESH_INTERVAL_MS : false, }) const logs = useMemo(() => { diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/hooks/use-table-event-stream.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/hooks/use-table-event-stream.ts index b56f64a8cc..92062e0008 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/hooks/use-table-event-stream.ts +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/hooks/use-table-event-stream.ts @@ -17,6 +17,7 @@ interface PrunedEvent { const RECONNECT_BACKOFF_MS = [500, 1_000, 2_000, 5_000, 10_000] const POINTER_PREFIX = 'table-event-stream-pointer:' +const DISPATCH_INVALIDATE_DEBOUNCE_MS = 250 function loadPointer(tableId: string): number { if (typeof window === 'undefined') return 0 @@ -73,6 +74,16 @@ export function useTableEventStream({ let lastEventId = loadPointer(tableId) let reconnectAttempt = 0 + // Trailing-edge debounce coalesces window-completion bursts. + let dispatchInvalidateTimer: ReturnType | null = null + const scheduleDispatchInvalidate = (): void => { + if (dispatchInvalidateTimer !== null) clearTimeout(dispatchInvalidateTimer) + dispatchInvalidateTimer = setTimeout(() => { + dispatchInvalidateTimer = null + void queryClient.invalidateQueries({ queryKey: tableKeys.activeDispatches(tableId) }) + }, DISPATCH_INVALIDATE_DEBOUNCE_MS) + } + // Keeps the per-row gutter (`runningByRowId`) live between dispatch events. // `runningCellCount` (the "X running" badge) is NOT touched here — it's the // server's dispatch-scope count, seeded optimistically on click and @@ -137,9 +148,7 @@ export function useTableEventStream({ if (wasInFlight === null) { // Row outside the loaded page slice — can't compute the delta locally. // Refetch the run-state snapshot from the server. Cheap and rare. - void queryClient.invalidateQueries({ - queryKey: tableKeys.activeDispatches(tableId), - }) + scheduleDispatchInvalidate() } else { updateRunningByRow(rowId, wasInFlight, isExecInFlight({ status } as RowExecutionMetadata)) } @@ -191,13 +200,13 @@ export function useTableEventStream({ // finish + the cursor advances) and on completion. Re-sync the // dispatch-scope `runningCellCount` from the server so the badge steps // down per window and matches a reload exactly. - void queryClient.invalidateQueries({ queryKey: tableKeys.activeDispatches(tableId) }) + scheduleDispatchInvalidate() } const handlePrune = (payload: PrunedEvent): void => { logger.info('Table event buffer pruned — full refetch', { tableId, ...payload }) void queryClient.invalidateQueries({ queryKey: tableKeys.rowsRoot(tableId) }) - void queryClient.invalidateQueries({ queryKey: tableKeys.activeDispatches(tableId) }) + scheduleDispatchInvalidate() lastEventId = typeof payload.earliestEventId === 'number' ? payload.earliestEventId : 0 savePointer(tableId, lastEventId) // Close proactively so the server's close doesn't fire onerror and route @@ -274,6 +283,7 @@ export function useTableEventStream({ return () => { cancelled = true if (reconnectTimer !== null) clearTimeout(reconnectTimer) + if (dispatchInvalidateTimer !== null) clearTimeout(dispatchInvalidateTimer) eventSource?.close() eventSource = null } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx index b22ab92094..45cade710c 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx @@ -213,7 +213,7 @@ function WorkflowToolDeployBadge({ workflowId: string onDeploySuccess?: () => void }) { - const { data, isLoading } = useDeploymentInfo(workflowId, { refetchOnMount: 'always' }) + const { data, isLoading } = useDeploymentInfo(workflowId) const { mutate, isPending: isDeploying } = useDeployWorkflow() const userPermissions = useUserPermissionsContext() diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/hooks/use-child-workflow.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/hooks/use-child-workflow.ts index 78b4126c8d..ab41beff60 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/hooks/use-child-workflow.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/hooks/use-child-workflow.ts @@ -40,8 +40,7 @@ export function useChildWorkflow( } const { data, isPending } = useDeploymentInfo( - isWorkflowSelector ? (childWorkflowId ?? null) : null, - { refetchOnMount: 'always' } + isWorkflowSelector ? (childWorkflowId ?? null) : null ) const childIsDeployed = data?.isDeployed ?? null diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-wand.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-wand.ts index 65170680b0..aac565e087 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-wand.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-wand.ts @@ -232,7 +232,7 @@ export function useWand({ }) setTimeout(() => { - queryClient.invalidateQueries({ queryKey: subscriptionKeys.all }) + queryClient.invalidateQueries({ queryKey: subscriptionKeys.users() }) }, 1000) } catch (error: any) { if (error.name === 'AbortError') { diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts index 47211da7b1..9584bd6504 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts @@ -763,7 +763,7 @@ export function useWorkflowExecution() { // Invalidate subscription queries to update usage setTimeout(() => { - queryClient.invalidateQueries({ queryKey: subscriptionKeys.all }) + queryClient.invalidateQueries({ queryKey: subscriptionKeys.users() }) }, 1000) safeEnqueue(encodeSSE({ event: 'final', data: result })) @@ -1296,7 +1296,7 @@ export function useWorkflowExecution() { setActiveBlocks(activeWorkflowId, new Set()) } setTimeout(() => { - queryClient.invalidateQueries({ queryKey: subscriptionKeys.all }) + queryClient.invalidateQueries({ queryKey: subscriptionKeys.users() }) }, 1000) } }, diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/usage-indicator/usage-indicator.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/usage-indicator/usage-indicator.tsx index 84247435a9..b1b311bf4e 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/usage-indicator/usage-indicator.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/usage-indicator/usage-indicator.tsx @@ -233,8 +233,8 @@ export function UsageIndicator({ onClick }: UsageIndicatorProps) { const handleOperationConfirmed = () => { clearTimeout(timeoutId) timeoutId = setTimeout(() => { - queryClient.invalidateQueries({ queryKey: subscriptionKeys.all }) - }, 1000) + queryClient.invalidateQueries({ queryKey: subscriptionKeys.users() }) + }, 5000) } onOperationConfirmed(handleOperationConfirmed) return () => clearTimeout(timeoutId) diff --git a/apps/sim/hooks/queries/subscription.ts b/apps/sim/hooks/queries/subscription.ts index ab8fd1d0e8..d201ec0672 100644 --- a/apps/sim/hooks/queries/subscription.ts +++ b/apps/sim/hooks/queries/subscription.ts @@ -53,7 +53,7 @@ interface UseSubscriptionDataOptions { * @param options - Optional configuration */ export function useSubscriptionData(options: UseSubscriptionDataOptions = {}) { - const { includeOrg = false, enabled = true, staleTime = 30 * 1000 } = options + const { includeOrg = false, enabled = true, staleTime = 5 * 60 * 1000 } = options return useQuery({ queryKey: subscriptionKeys.user(includeOrg), @@ -72,7 +72,7 @@ export function prefetchSubscriptionData(queryClient: QueryClient) { queryClient.prefetchQuery({ queryKey: subscriptionKeys.user(false), queryFn: ({ signal }) => fetchSubscriptionData(false, signal), - staleTime: 30 * 1000, + staleTime: 5 * 60 * 1000, }) } diff --git a/apps/sim/lib/copilot/async-runs/repository.ts b/apps/sim/lib/copilot/async-runs/repository.ts index b769682441..4e8c3d236b 100644 --- a/apps/sim/lib/copilot/async-runs/repository.ts +++ b/apps/sim/lib/copilot/async-runs/repository.ts @@ -186,7 +186,11 @@ export async function getRunSegment(runId: string) { 'copilot_runs', { [TraceAttr.RunId]: runId }, async () => { - const [run] = await db.select().from(copilotRuns).where(eq(copilotRuns.id, runId)).limit(1) + const [run] = await db + .select({ id: copilotRuns.id, userId: copilotRuns.userId }) + .from(copilotRuns) + .where(eq(copilotRuns.id, runId)) + .limit(1) return run ?? null } ) diff --git a/apps/sim/lib/copilot/tools/handlers/deployment/manage.ts b/apps/sim/lib/copilot/tools/handlers/deployment/manage.ts index 5caf4451ad..bd84602a56 100644 --- a/apps/sim/lib/copilot/tools/handlers/deployment/manage.ts +++ b/apps/sim/lib/copilot/tools/handlers/deployment/manage.ts @@ -37,9 +37,23 @@ export async function executeCheckDeploymentStatus( const workspaceId = workflowRecord.workspaceId const [apiDeploy, chatDeploy] = await Promise.all([ - db.select().from(workflow).where(eq(workflow.id, workflowId)).limit(1), db - .select() + .select({ isDeployed: workflow.isDeployed, deployedAt: workflow.deployedAt }) + .from(workflow) + .where(eq(workflow.id, workflowId)) + .limit(1), + db + .select({ + id: chat.id, + identifier: chat.identifier, + title: chat.title, + description: chat.description, + authType: chat.authType, + allowedEmails: chat.allowedEmails, + outputConfigs: chat.outputConfigs, + password: chat.password, + customizations: chat.customizations, + }) .from(chat) .where(and(eq(chat.workflowId, workflowId), isNull(chat.archivedAt))) .limit(1), diff --git a/apps/sim/lib/credentials/environment.ts b/apps/sim/lib/credentials/environment.ts index 0ace988407..6013af5349 100644 --- a/apps/sim/lib/credentials/environment.ts +++ b/apps/sim/lib/credentials/environment.ts @@ -1,7 +1,7 @@ import { db } from '@sim/db' import { credential, credentialMember, permissions, workspace } from '@sim/db/schema' import { generateId } from '@sim/utils/id' -import { and, eq, inArray, isNull, notInArray } from 'drizzle-orm' +import { and, eq, inArray, isNull, notInArray, sql } from 'drizzle-orm' interface AccessibleEnvCredential { type: 'env_workspace' | 'env_personal' @@ -10,12 +10,6 @@ interface AccessibleEnvCredential { updatedAt: Date } -function getPostgresErrorCode(error: unknown): string | undefined { - if (!error || typeof error !== 'object') return undefined - const err = error as { code?: string; cause?: { code?: string } } - return err.code || err.cause?.code -} - export async function getWorkspaceMemberUserIds(workspaceId: string): Promise { const [workspaceRows, permissionRows] = await Promise.all([ db @@ -70,10 +64,8 @@ async function ensureWorkspaceCredentialMemberships( const existingMemberships = await db .select({ - id: credentialMember.id, userId: credentialMember.userId, status: credentialMember.status, - joinedAt: credentialMember.joinedAt, }) .from(credentialMember) .where( @@ -83,41 +75,40 @@ async function ensureWorkspaceCredentialMemberships( ) ) - const byUserId = new Map(existingMemberships.map((row) => [row.userId, row])) - const now = new Date() - - for (const memberUserId of memberUserIds) { - const targetRole = memberUserId === ownerUserId ? 'admin' : 'member' - const existing = byUserId.get(memberUserId) - if (existing) { - if (existing.status === 'revoked') { - continue - } - await db - .update(credentialMember) - .set({ - role: targetRole, - status: 'active', - joinedAt: existing.joinedAt ?? now, - invitedBy: ownerUserId, - updatedAt: now, - }) - .where(eq(credentialMember.id, existing.id)) - continue - } + // Revoked memberships are filtered out so ON CONFLICT cannot resurrect them. + const revokedUserIds = new Set( + existingMemberships.filter((row) => row.status === 'revoked').map((row) => row.userId) + ) + const targetUserIds = memberUserIds.filter((id) => !revokedUserIds.has(id)) + if (targetUserIds.length === 0) return - await db.insert(credentialMember).values({ - id: generateId(), - credentialId, - userId: memberUserId, - role: targetRole, - status: 'active', - joinedAt: now, - invitedBy: ownerUserId, - createdAt: now, - updatedAt: now, + const now = new Date() + const values = targetUserIds.map((memberUserId) => ({ + id: generateId(), + credentialId, + userId: memberUserId, + role: (memberUserId === ownerUserId ? 'admin' : 'member') as 'admin' | 'member', + status: 'active' as const, + joinedAt: now, + invitedBy: ownerUserId, + createdAt: now, + updatedAt: now, + })) + + // `joinedAt` uses COALESCE so a non-null existing value is preserved but null is backfilled. + await db + .insert(credentialMember) + .values(values) + .onConflictDoUpdate({ + target: [credentialMember.credentialId, credentialMember.userId], + set: { + role: sql`excluded.role`, + status: 'active', + joinedAt: sql`COALESCE(${credentialMember.joinedAt}, excluded.joined_at)`, + invitedBy: ownerUserId, + updatedAt: now, + }, }) - } } export async function syncWorkspaceEnvCredentials(params: { @@ -157,27 +148,29 @@ export async function syncWorkspaceEnvCredentials(params: { for (const envKey of normalizedKeys) { const existingId = existingByKey.get(envKey) - if (existingId) { - credentialIdsToEnsureMembership.add(existingId) - continue - } + if (existingId) credentialIdsToEnsureMembership.add(existingId) + } - const createdId = generateId() - try { - await db.insert(credential).values({ - id: createdId, - workspaceId, - type: 'env_workspace', - displayName: envKey, - envKey, - createdBy: actingUserId, - createdAt: now, - updatedAt: now, - }) - credentialIdsToEnsureMembership.add(createdId) - } catch (error: unknown) { - const code = getPostgresErrorCode(error) - if (code !== '23505') throw error + const keysToCreate = normalizedKeys.filter((key) => !existingByKey.has(key)) + if (keysToCreate.length > 0) { + const inserted = await db + .insert(credential) + .values( + keysToCreate.map((envKey) => ({ + id: generateId(), + workspaceId, + type: 'env_workspace' as const, + displayName: envKey, + envKey, + createdBy: actingUserId, + createdAt: now, + updatedAt: now, + })) + ) + .onConflictDoNothing() + .returning({ id: credential.id }) + for (const row of inserted) { + credentialIdsToEnsureMembership.add(row.id) } } @@ -229,27 +222,24 @@ export async function createWorkspaceEnvCredentials(params: { const ownerUserId = workspaceRow.ownerId const now = new Date() - const createdIds: string[] = [] - for (const envKey of keys) { - const createdId = generateId() - try { - await db.insert(credential).values({ - id: createdId, + const inserted = await db + .insert(credential) + .values( + keys.map((envKey) => ({ + id: generateId(), workspaceId, - type: 'env_workspace', + type: 'env_workspace' as const, displayName: envKey, envKey, createdBy: actingUserId, createdAt: now, updatedAt: now, - }) - createdIds.push(createdId) - } catch (error: unknown) { - const code = getPostgresErrorCode(error) - if (code !== '23505') throw error - } - } + })) + ) + .onConflictDoNothing() + .returning({ id: credential.id }) + const createdIds = inserted.map((row) => row.id) if (createdIds.length === 0 || memberUserIds.length === 0) return diff --git a/apps/sim/lib/mcp/orchestration/workflow-mcp-lifecycle.ts b/apps/sim/lib/mcp/orchestration/workflow-mcp-lifecycle.ts index 95f341b427..4d04a98189 100644 --- a/apps/sim/lib/mcp/orchestration/workflow-mcp-lifecycle.ts +++ b/apps/sim/lib/mcp/orchestration/workflow-mcp-lifecycle.ts @@ -226,9 +226,9 @@ export async function performUpdateWorkflowMcpServer( const updatedFields = Object.keys(updateData).filter((key) => key !== 'updatedAt') try { - const [existingServer] = await db - .select({ id: workflowMcpServer.id }) - .from(workflowMcpServer) + const [server] = await db + .update(workflowMcpServer) + .set(updateData) .where( and( eq(workflowMcpServer.id, params.serverId), @@ -236,18 +236,12 @@ export async function performUpdateWorkflowMcpServer( isNull(workflowMcpServer.deletedAt) ) ) - .limit(1) + .returning() - if (!existingServer) { + if (!server) { return { success: false, error: 'Server not found', errorCode: 'not_found' } } - const [server] = await db - .update(workflowMcpServer) - .set(updateData) - .where(and(eq(workflowMcpServer.id, params.serverId), isNull(workflowMcpServer.deletedAt))) - .returning() - recordAudit({ workspaceId: params.workspaceId, actorId: params.userId, @@ -465,20 +459,6 @@ export async function performUpdateWorkflowMcpTool( if (!server) return { success: false, error: 'Server not found', errorCode: 'not_found' } - const [existingTool] = await db - .select({ id: workflowMcpTool.id }) - .from(workflowMcpTool) - .where( - and( - eq(workflowMcpTool.id, params.toolId), - eq(workflowMcpTool.serverId, params.serverId), - isNull(workflowMcpTool.archivedAt) - ) - ) - .limit(1) - - if (!existingTool) return { success: false, error: 'Tool not found', errorCode: 'not_found' } - const updateData: Partial = { updatedAt: new Date() } if (params.toolName !== undefined) updateData.toolName = sanitizeToolName(params.toolName) if (params.toolDescription !== undefined) { @@ -491,9 +471,17 @@ export async function performUpdateWorkflowMcpTool( const [tool] = await db .update(workflowMcpTool) .set(updateData) - .where(eq(workflowMcpTool.id, params.toolId)) + .where( + and( + eq(workflowMcpTool.id, params.toolId), + eq(workflowMcpTool.serverId, params.serverId), + isNull(workflowMcpTool.archivedAt) + ) + ) .returning() + if (!tool) return { success: false, error: 'Tool not found', errorCode: 'not_found' } + mcpPubSub?.publishWorkflowToolsChanged({ serverId: params.serverId, workspaceId: params.workspaceId, diff --git a/apps/sim/lib/templates/permissions.ts b/apps/sim/lib/templates/permissions.ts index 61b08fc86c..7c5f80c6e3 100644 --- a/apps/sim/lib/templates/permissions.ts +++ b/apps/sim/lib/templates/permissions.ts @@ -94,7 +94,10 @@ export async function verifyCreatorPermission( requiredLevel: CreatorPermissionLevel = 'admin' ): Promise<{ hasPermission: boolean; error?: string }> { const creatorProfile = await db - .select() + .select({ + referenceType: templateCreators.referenceType, + referenceId: templateCreators.referenceId, + }) .from(templateCreators) .where(eq(templateCreators.id, creatorId)) .limit(1) diff --git a/apps/sim/lib/webhooks/polling/utils.ts b/apps/sim/lib/webhooks/polling/utils.ts index 7d42a2e7a6..277128699f 100644 --- a/apps/sim/lib/webhooks/polling/utils.ts +++ b/apps/sim/lib/webhooks/polling/utils.ts @@ -7,7 +7,7 @@ import { workflowDeploymentVersion, } from '@sim/db/schema' import type { Logger } from '@sim/logger' -import { and, eq, isNull, or, sql } from 'drizzle-orm' +import { and, eq, isNull, ne, or, sql } from 'drizzle-orm' import { isOrganizationOnTeamOrEnterprisePlan } from '@/lib/billing' import type { WebhookRecord, WorkflowRecord } from '@/lib/webhooks/polling/types' import { @@ -61,7 +61,9 @@ export async function markWebhookSuccess(webhookId: string, logger: Logger): Pro failedCount: 0, updatedAt: new Date(), }) - .where(eq(webhook.id, webhookId)) + .where( + and(eq(webhook.id, webhookId), or(isNull(webhook.failedCount), ne(webhook.failedCount, 0))) + ) } catch (err) { logger.error(`Failed to mark webhook ${webhookId} as successful:`, err) } diff --git a/apps/sim/lib/workflows/orchestration/chat-deploy.ts b/apps/sim/lib/workflows/orchestration/chat-deploy.ts index d8c9325316..8b153a6afe 100644 --- a/apps/sim/lib/workflows/orchestration/chat-deploy.ts +++ b/apps/sim/lib/workflows/orchestration/chat-deploy.ts @@ -202,7 +202,16 @@ export async function performChatUndeploy( ): Promise { const { chatId, userId, workspaceId } = params - const [chatRecord] = await db.select().from(chat).where(eq(chat.id, chatId)).limit(1) + const [chatRecord] = await db + .select({ + title: chat.title, + workflowId: chat.workflowId, + identifier: chat.identifier, + authType: chat.authType, + }) + .from(chat) + .where(eq(chat.id, chatId)) + .limit(1) if (!chatRecord) { return { success: false, error: 'Chat not found' } diff --git a/packages/db/migrations/0211_breezy_cloak.sql b/packages/db/migrations/0211_breezy_cloak.sql new file mode 100644 index 0000000000..935ea8f649 --- /dev/null +++ b/packages/db/migrations/0211_breezy_cloak.sql @@ -0,0 +1,4 @@ +CREATE INDEX "idx_chat_on_workflow_id_archived_at" ON "chat" USING btree ("workflow_id","archived_at");--> statement-breakpoint +CREATE INDEX "idx_webhook_on_provider_is_active_workflow_id_deploym_bdeed5468" ON "webhook" USING btree ("provider","is_active","workflow_id","deployment_version_id");--> statement-breakpoint +CREATE INDEX "idx_webhook_on_workflow_id_block_id_updated_at_desc" ON "webhook" USING btree ("workflow_id","block_id","updated_at" DESC NULLS LAST);--> statement-breakpoint +CREATE INDEX "idx_workflow_schedule_on_source_workspace_id_source_t_c07f3bba6" ON "workflow_schedule" USING btree ("source_workspace_id","source_type","archived_at","status"); diff --git a/packages/db/migrations/meta/0211_snapshot.json b/packages/db/migrations/meta/0211_snapshot.json new file mode 100644 index 0000000000..9975012b61 --- /dev/null +++ b/packages/db/migrations/meta/0211_snapshot.json @@ -0,0 +1,16710 @@ +{ + "id": "d0a90187-fce8-43a6-9748-78637c8b18ac", + "prevId": "0a966f65-38de-4bb8-966b-f5a99b39acb1", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.a2a_agent": { + "name": "a2a_agent", + "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": 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 + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'1.0.0'" + }, + "capabilities": { + "name": "capabilities", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "skills": { + "name": "skills", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "authentication": { + "name": "authentication", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "signatures": { + "name": "signatures", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "is_published": { + "name": "is_published", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "published_at": { + "name": "published_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": { + "a2a_agent_workflow_id_idx": { + "name": "a2a_agent_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "a2a_agent_created_by_idx": { + "name": "a2a_agent_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "a2a_agent_workspace_workflow_unique": { + "name": "a2a_agent_workspace_workflow_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"a2a_agent\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "a2a_agent_archived_at_idx": { + "name": "a2a_agent_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "a2a_agent_workspace_archived_partial_idx": { + "name": "a2a_agent_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": "\"a2a_agent\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "a2a_agent_workspace_id_workspace_id_fk": { + "name": "a2a_agent_workspace_id_workspace_id_fk", + "tableFrom": "a2a_agent", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "a2a_agent_workflow_id_workflow_id_fk": { + "name": "a2a_agent_workflow_id_workflow_id_fk", + "tableFrom": "a2a_agent", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "a2a_agent_created_by_user_id_fk": { + "name": "a2a_agent_created_by_user_id_fk", + "tableFrom": "a2a_agent", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.a2a_push_notification_config": { + "name": "a2a_push_notification_config", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "auth_schemes": { + "name": "auth_schemes", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "auth_credentials": { + "name": "auth_credentials", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": 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": { + "a2a_push_notification_config_task_unique": { + "name": "a2a_push_notification_config_task_unique", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "a2a_push_notification_config_task_id_a2a_task_id_fk": { + "name": "a2a_push_notification_config_task_id_a2a_task_id_fk", + "tableFrom": "a2a_push_notification_config", + "tableTo": "a2a_task", + "columnsFrom": ["task_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.a2a_task": { + "name": "a2a_task", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "a2a_task_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'submitted'" + }, + "messages": { + "name": "messages", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "artifacts": { + "name": "artifacts", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "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()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "a2a_task_agent_id_idx": { + "name": "a2a_task_agent_id_idx", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "a2a_task_session_id_idx": { + "name": "a2a_task_session_id_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "a2a_task_status_idx": { + "name": "a2a_task_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "a2a_task_execution_id_idx": { + "name": "a2a_task_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "a2a_task_created_at_idx": { + "name": "a2a_task_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "a2a_task_agent_id_a2a_agent_id_fk": { + "name": "a2a_task_agent_id_a2a_agent_id_fk", + "tableFrom": "a2a_task", + "tableTo": "a2a_agent", + "columnsFrom": ["agent_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "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_number_idx": { + "name": "academy_certificate_number_idx", + "columns": [ + { + "expression": "certificate_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "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": {} + } + }, + "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.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": "'[]'" + }, + "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 + }, + "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_tool_call_id_idx": { + "name": "copilot_async_tool_calls_tool_call_id_idx", + "columns": [ + { + "expression": "tool_call_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 + }, + "messages": { + "name": "messages", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "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": "'[]'" + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "pinned": { + "name": "pinned", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": 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": {} + } + }, + "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_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 + }, + "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 + }, + "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_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_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_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_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.credential_set": { + "name": "credential_set", + "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 + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "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_set_created_by_idx": { + "name": "credential_set_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_set_org_name_unique": { + "name": "credential_set_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": {} + }, + "credential_set_provider_id_idx": { + "name": "credential_set_provider_id_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_set_organization_id_organization_id_fk": { + "name": "credential_set_organization_id_organization_id_fk", + "tableFrom": "credential_set", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_set_created_by_user_id_fk": { + "name": "credential_set_created_by_user_id_fk", + "tableFrom": "credential_set", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credential_set_invitation": { + "name": "credential_set_invitation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "credential_set_id": { + "name": "credential_set_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "credential_set_invitation_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "accepted_at": { + "name": "accepted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "accepted_by_user_id": { + "name": "accepted_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_set_invitation_set_id_idx": { + "name": "credential_set_invitation_set_id_idx", + "columns": [ + { + "expression": "credential_set_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_set_invitation_token_idx": { + "name": "credential_set_invitation_token_idx", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_set_invitation_status_idx": { + "name": "credential_set_invitation_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_set_invitation_expires_at_idx": { + "name": "credential_set_invitation_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_set_invitation_credential_set_id_credential_set_id_fk": { + "name": "credential_set_invitation_credential_set_id_credential_set_id_fk", + "tableFrom": "credential_set_invitation", + "tableTo": "credential_set", + "columnsFrom": ["credential_set_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_set_invitation_invited_by_user_id_fk": { + "name": "credential_set_invitation_invited_by_user_id_fk", + "tableFrom": "credential_set_invitation", + "tableTo": "user", + "columnsFrom": ["invited_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_set_invitation_accepted_by_user_id_user_id_fk": { + "name": "credential_set_invitation_accepted_by_user_id_user_id_fk", + "tableFrom": "credential_set_invitation", + "tableTo": "user", + "columnsFrom": ["accepted_by_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "credential_set_invitation_token_unique": { + "name": "credential_set_invitation_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credential_set_member": { + "name": "credential_set_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "credential_set_id": { + "name": "credential_set_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "credential_set_member_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "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_set_member_user_id_idx": { + "name": "credential_set_member_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_set_member_unique": { + "name": "credential_set_member_unique", + "columns": [ + { + "expression": "credential_set_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_set_member_status_idx": { + "name": "credential_set_member_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_set_member_credential_set_id_credential_set_id_fk": { + "name": "credential_set_member_credential_set_id_credential_set_id_fk", + "tableFrom": "credential_set_member", + "tableTo": "credential_set", + "columnsFrom": ["credential_set_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_set_member_user_id_user_id_fk": { + "name": "credential_set_member_user_id_user_id_fk", + "tableFrom": "credential_set_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_set_member_invited_by_user_id_fk": { + "name": "credential_set_member_invited_by_user_id_fk", + "tableFrom": "credential_set_member", + "tableTo": "user", + "columnsFrom": ["invited_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 + }, + "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_started_at": { + "name": "processing_started_at", + "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 + }, + "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_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" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "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 + }, + "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.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.form": { + "name": "form", + "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": "'[]'" + }, + "show_branding": { + "name": "show_branding", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "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": { + "form_identifier_idx": { + "name": "form_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"form\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "form_workflow_id_idx": { + "name": "form_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "form_user_id_idx": { + "name": "form_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "form_archived_at_partial_idx": { + "name": "form_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"form\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "form_workflow_id_workflow_id_fk": { + "name": "form_workflow_id_workflow_id_fk", + "tableFrom": "form", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "form_user_id_user_id_fk": { + "name": "form_user_id_user_id_fk", + "tableFrom": "form", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "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.jwks": { + "name": "jwks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "private_key": { + "name": "private_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "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 + }, + "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_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": "no action", + "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 + }, + "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_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_id_idx": { + "name": "kcsl_connector_id_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "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 + }, + "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.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": { + "mothership_settings_workspace_id_idx": { + "name": "mothership_settings_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "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.oauth_access_token": { + "name": "oauth_access_token", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "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 + } + }, + "indexes": { + "oauth_access_token_access_token_idx": { + "name": "oauth_access_token_access_token_idx", + "columns": [ + { + "expression": "access_token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_access_token_refresh_token_idx": { + "name": "oauth_access_token_refresh_token_idx", + "columns": [ + { + "expression": "refresh_token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_access_token_client_id_oauth_application_client_id_fk": { + "name": "oauth_access_token_client_id_oauth_application_client_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "oauth_application", + "columnsFrom": ["client_id"], + "columnsTo": ["client_id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_access_token_user_id_user_id_fk": { + "name": "oauth_access_token_user_id_user_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_access_token_access_token_unique": { + "name": "oauth_access_token_access_token_unique", + "nullsNotDistinct": false, + "columns": ["access_token"] + }, + "oauth_access_token_refresh_token_unique": { + "name": "oauth_access_token_refresh_token_unique", + "nullsNotDistinct": false, + "columns": ["refresh_token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_application": { + "name": "oauth_application", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_secret": { + "name": "client_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "redirect_urls": { + "name": "redirect_urls", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "disabled": { + "name": "disabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "user_id": { + "name": "user_id", + "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": { + "oauth_application_client_id_idx": { + "name": "oauth_application_client_id_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_application_user_id_user_id_fk": { + "name": "oauth_application_user_id_user_id_fk", + "tableFrom": "oauth_application", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_application_client_id_unique": { + "name": "oauth_application_client_id_unique", + "nullsNotDistinct": false, + "columns": ["client_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_consent": { + "name": "oauth_consent", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scopes": { + "name": "scopes", + "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 + }, + "consent_given": { + "name": "consent_given", + "type": "boolean", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "oauth_consent_user_client_idx": { + "name": "oauth_consent_user_client_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_consent_client_id_oauth_application_client_id_fk": { + "name": "oauth_consent_client_id_oauth_application_client_id_fk", + "tableFrom": "oauth_consent", + "tableTo": "oauth_application", + "columnsFrom": ["client_id"], + "columnsTo": ["client_id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_consent_user_id_user_id_fk": { + "name": "oauth_consent_user_id_user_id_fk", + "tableFrom": "oauth_consent", + "tableTo": "user", + "columnsFrom": ["user_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 + }, + "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 + }, + "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.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": {} + } + }, + "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 + }, + "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 + }, + "workspace_id": { + "name": "workspace_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()" + }, + "auto_add_new_members": { + "name": "auto_add_new_members", + "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_workspace_name_unique": { + "name": "permission_group_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": {} + }, + "permission_group_workspace_auto_add_unique": { + "name": "permission_group_workspace_auto_add_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "auto_add_new_members = true", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_group_workspace_id_workspace_id_fk": { + "name": "permission_group_workspace_id_workspace_id_fk", + "tableFrom": "permission_group", + "tableTo": "workspace", + "columnsFrom": ["workspace_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 + }, + "workspace_id": { + "name": "workspace_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_workspace_user_unique": { + "name": "permission_group_member_workspace_user_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "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_workspace_id_workspace_id_fk": { + "name": "permission_group_member_workspace_id_workspace_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "workspace", + "columnsFrom": ["workspace_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.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_user_entity_idx": { + "name": "permissions_user_entity_idx", + "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": 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.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.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": {} + }, + "session_token_idx": { + "name": "session_token_idx", + "columns": [ + { + "expression": "token", + "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 + }, + "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.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.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 + } + }, + "indexes": { + "sso_provider_provider_id_idx": { + "name": "sso_provider_provider_id_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "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 + }, + "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 + }, + "metadata": { + "name": "metadata", + "type": "json", + "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_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 + }, + "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 + }, + "is_manual_run": { + "name": "is_manual_run", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "requested_at": { + "name": "requested_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "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" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.template_creators": { + "name": "template_creators", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "reference_type": { + "name": "reference_type", + "type": "template_creator_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "profile_image_url": { + "name": "profile_image_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "details": { + "name": "details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "verified": { + "name": "verified", + "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": { + "template_creators_reference_idx": { + "name": "template_creators_reference_idx", + "columns": [ + { + "expression": "reference_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "reference_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "template_creators_reference_id_idx": { + "name": "template_creators_reference_id_idx", + "columns": [ + { + "expression": "reference_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "template_creators_created_by_idx": { + "name": "template_creators_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "template_creators_created_by_user_id_fk": { + "name": "template_creators_created_by_user_id_fk", + "tableFrom": "template_creators", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.template_stars": { + "name": "template_stars", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "template_id": { + "name": "template_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "starred_at": { + "name": "starred_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "template_stars_user_id_idx": { + "name": "template_stars_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "template_stars_template_id_idx": { + "name": "template_stars_template_id_idx", + "columns": [ + { + "expression": "template_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "template_stars_user_template_idx": { + "name": "template_stars_user_template_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "template_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "template_stars_template_user_idx": { + "name": "template_stars_template_user_idx", + "columns": [ + { + "expression": "template_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "template_stars_starred_at_idx": { + "name": "template_stars_starred_at_idx", + "columns": [ + { + "expression": "starred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "template_stars_template_starred_at_idx": { + "name": "template_stars_template_starred_at_idx", + "columns": [ + { + "expression": "template_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "starred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "template_stars_user_template_unique": { + "name": "template_stars_user_template_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "template_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "template_stars_user_id_user_id_fk": { + "name": "template_stars_user_id_user_id_fk", + "tableFrom": "template_stars", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "template_stars_template_id_templates_id_fk": { + "name": "template_stars_template_id_templates_id_fk", + "tableFrom": "template_stars", + "tableTo": "templates", + "columnsFrom": ["template_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.templates": { + "name": "templates", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "details": { + "name": "details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "creator_id": { + "name": "creator_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "views": { + "name": "views", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "stars": { + "name": "stars", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "template_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "tags": { + "name": "tags", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "required_credentials": { + "name": "required_credentials", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "state": { + "name": "state", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "og_image_url": { + "name": "og_image_url", + "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": { + "templates_status_idx": { + "name": "templates_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "templates_creator_id_idx": { + "name": "templates_creator_id_idx", + "columns": [ + { + "expression": "creator_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "templates_views_idx": { + "name": "templates_views_idx", + "columns": [ + { + "expression": "views", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "templates_stars_idx": { + "name": "templates_stars_idx", + "columns": [ + { + "expression": "stars", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "templates_status_views_idx": { + "name": "templates_status_views_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "views", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "templates_status_stars_idx": { + "name": "templates_status_stars_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "stars", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "templates_created_at_idx": { + "name": "templates_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "templates_updated_at_idx": { + "name": "templates_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "templates_workflow_id_workflow_id_fk": { + "name": "templates_workflow_id_workflow_id_fk", + "tableFrom": "templates", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "templates_creator_id_template_creators_id_fk": { + "name": "templates_creator_id_template_creators_id_fk", + "tableFrom": "templates", + "tableTo": "template_creators", + "columnsFrom": ["creator_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "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 + }, + "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_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": {} + } + }, + "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": {}, + "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_a2a_executions": { + "name": "total_a2a_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 + } + }, + "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 + }, + "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 + }, + "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_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_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_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 + }, + "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_table_id_idx": { + "name": "user_table_rows_table_id_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_data_gin_idx": { + "name": "user_table_rows_data_gin_idx", + "columns": [ + { + "expression": "data", + "isExpression": false, + "asc": 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": {} + } + }, + "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 + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "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 + }, + "credential_set_id": { + "name": "credential_set_id", + "type": "text", + "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": {} + }, + "idx_webhook_on_workflow_id_block_id": { + "name": "idx_webhook_on_workflow_id_block_id", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "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_credential_set_id_idx": { + "name": "webhook_credential_set_id_idx", + "columns": [ + { + "expression": "credential_set_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "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": {} + } + }, + "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" + }, + "webhook_credential_set_id_credential_set_id_fk": { + "name": "webhook_credential_set_id_credential_set_id_fk", + "tableFrom": "webhook", + "tableTo": "credential_set", + "columnsFrom": ["credential_set_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "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 + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'#3972F6'" + }, + "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 + }, + "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_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_workflow_folder_id_fk": { + "name": "workflow_folder_id_workflow_folder_id_fk", + "tableFrom": "workflow", + "tableTo": "workflow_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 + }, + "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_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 + }, + "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 + }, + "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_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": {} + } + }, + "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_folder": { + "name": "workflow_folder", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "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 + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'#6B7280'" + }, + "is_expanded": { + "name": "is_expanded", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "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()" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workflow_folder_user_idx": { + "name": "workflow_folder_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_workspace_parent_idx": { + "name": "workflow_folder_workspace_parent_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_parent_sort_idx": { + "name": "workflow_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": {} + }, + "workflow_folder_archived_at_idx": { + "name": "workflow_folder_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_workspace_archived_partial_idx": { + "name": "workflow_folder_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_folder\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_folder_user_id_user_id_fk": { + "name": "workflow_folder_user_id_user_id_fk", + "tableFrom": "workflow_folder", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_folder_workspace_id_workspace_id_fk": { + "name": "workflow_folder_workspace_id_workspace_id_fk", + "tableFrom": "workflow_folder", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "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": "'{}'" + }, + "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 + }, + "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 + }, + "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 + }, + "job_history": { + "name": "job_history", + "type": "jsonb", + "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": {} + } + }, + "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_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 + }, + "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 + }, + "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": { + "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": {} + } + }, + "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" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "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 + }, + "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_provider_unique": { + "name": "workspace_byok_provider_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_byok_workspace_idx": { + "name": "workspace_byok_workspace_idx", + "columns": [ + { + "expression": "workspace_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_key_idx": { + "name": "workspace_file_key_idx", + "columns": [ + { + "expression": "key", + "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_folders": { + "name": "workspace_file_folders", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "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 + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "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": { + "workspace_file_folders_workspace_parent_idx": { + "name": "workspace_file_folders_workspace_parent_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_folders_parent_sort_idx": { + "name": "workspace_file_folders_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": {} + }, + "workspace_file_folders_deleted_at_idx": { + "name": "workspace_file_folders_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_folders_workspace_deleted_partial_idx": { + "name": "workspace_file_folders_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_folders\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_folders_workspace_parent_name_active_unique": { + "name": "workspace_file_folders_workspace_parent_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "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": "\"workspace_file_folders\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_file_folders_user_id_user_id_fk": { + "name": "workspace_file_folders_user_id_user_id_fk", + "tableFrom": "workspace_file_folders", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_file_folders_workspace_id_workspace_id_fk": { + "name": "workspace_file_folders_workspace_id_workspace_id_fk", + "tableFrom": "workspace_file_folders", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_file_folders_parent_id_workspace_file_folders_id_fk": { + "name": "workspace_file_folders_parent_id_workspace_file_folders_id_fk", + "tableFrom": "workspace_file_folders", + "tableTo": "workspace_file_folders", + "columnsFrom": ["parent_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "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 + }, + "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 + }, + "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()" + } + }, + "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_workspace_file_folders_id_fk": { + "name": "workspace_files_folder_id_workspace_file_folders_id_fk", + "tableFrom": "workspace_files", + "tableTo": "workspace_file_folders", + "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_notification_delivery": { + "name": "workspace_notification_delivery", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "subscription_id": { + "name": "subscription_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "notification_delivery_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_attempt_at": { + "name": "last_attempt_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "next_attempt_at": { + "name": "next_attempt_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "response_status": { + "name": "response_status", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "response_body": { + "name": "response_body", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "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_notification_delivery_subscription_id_idx": { + "name": "workspace_notification_delivery_subscription_id_idx", + "columns": [ + { + "expression": "subscription_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_notification_delivery_execution_id_idx": { + "name": "workspace_notification_delivery_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_notification_delivery_status_idx": { + "name": "workspace_notification_delivery_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_notification_delivery_next_attempt_idx": { + "name": "workspace_notification_delivery_next_attempt_idx", + "columns": [ + { + "expression": "next_attempt_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_notification_delivery_subscription_id_workspace_notification_subscription_id_fk": { + "name": "workspace_notification_delivery_subscription_id_workspace_notification_subscription_id_fk", + "tableFrom": "workspace_notification_delivery", + "tableTo": "workspace_notification_subscription", + "columnsFrom": ["subscription_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_notification_delivery_workflow_id_workflow_id_fk": { + "name": "workspace_notification_delivery_workflow_id_workflow_id_fk", + "tableFrom": "workspace_notification_delivery", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_notification_subscription": { + "name": "workspace_notification_subscription", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "notification_type": { + "name": "notification_type", + "type": "notification_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "workflow_ids": { + "name": "workflow_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "all_workflows": { + "name": "all_workflows", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "level_filter": { + "name": "level_filter", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "ARRAY['info', 'error']::text[]" + }, + "trigger_filter": { + "name": "trigger_filter", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "ARRAY['api', 'webhook', 'schedule', 'manual', 'chat']::text[]" + }, + "include_final_output": { + "name": "include_final_output", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "include_trace_spans": { + "name": "include_trace_spans", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "include_rate_limits": { + "name": "include_rate_limits", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "include_usage_data": { + "name": "include_usage_data", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "webhook_config": { + "name": "webhook_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "email_recipients": { + "name": "email_recipients", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "slack_config": { + "name": "slack_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "alert_config": { + "name": "alert_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "last_alert_at": { + "name": "last_alert_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "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": { + "workspace_notification_workspace_id_idx": { + "name": "workspace_notification_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_notification_active_idx": { + "name": "workspace_notification_active_idx", + "columns": [ + { + "expression": "active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_notification_type_idx": { + "name": "workspace_notification_type_idx", + "columns": [ + { + "expression": "notification_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_notification_subscription_workspace_id_workspace_id_fk": { + "name": "workspace_notification_subscription_workspace_id_workspace_id_fk", + "tableFrom": "workspace_notification_subscription", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_notification_subscription_created_by_user_id_fk": { + "name": "workspace_notification_subscription_created_by_user_id_fk", + "tableFrom": "workspace_notification_subscription", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.a2a_task_status": { + "name": "a2a_task_status", + "schema": "public", + "values": [ + "submitted", + "working", + "input-required", + "completed", + "failed", + "canceled", + "rejected", + "auth-required", + "unknown" + ] + }, + "public.academy_cert_status": { + "name": "academy_cert_status", + "schema": "public", + "values": ["active", "revoked", "expired"] + }, + "public.billing_blocked_reason": { + "name": "billing_blocked_reason", + "schema": "public", + "values": ["payment_failed", "dispute"] + }, + "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.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_set_invitation_status": { + "name": "credential_set_invitation_status", + "schema": "public", + "values": ["pending", "accepted", "expired", "cancelled"] + }, + "public.credential_set_member_status": { + "name": "credential_set_member_status", + "schema": "public", + "values": ["active", "pending", "revoked"] + }, + "public.credential_type": { + "name": "credential_type", + "schema": "public", + "values": ["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.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.notification_delivery_status": { + "name": "notification_delivery_status", + "schema": "public", + "values": ["pending", "in_progress", "success", "failed"] + }, + "public.notification_type": { + "name": "notification_type", + "schema": "public", + "values": ["webhook", "email", "slack"] + }, + "public.permission_type": { + "name": "permission_type", + "schema": "public", + "values": ["admin", "write", "read"] + }, + "public.template_creator_type": { + "name": "template_creator_type", + "schema": "public", + "values": ["user", "organization"] + }, + "public.template_status": { + "name": "template_status", + "schema": "public", + "values": ["pending", "approved", "rejected"] + }, + "public.usage_log_category": { + "name": "usage_log_category", + "schema": "public", + "values": ["model", "fixed"] + }, + "public.usage_log_source": { + "name": "usage_log_source", + "schema": "public", + "values": [ + "workflow", + "wand", + "copilot", + "workspace-chat", + "mcp_copilot", + "mothership_block", + "knowledge-base", + "voice-input" + ] + }, + "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 6985b5a89b..10f27d5d54 100644 --- a/packages/db/migrations/meta/_journal.json +++ b/packages/db/migrations/meta/_journal.json @@ -1471,6 +1471,13 @@ "when": 1779299303134, "tag": "0210_mcp_oauth", "breakpoints": true + }, + { + "idx": 211, + "version": "7", + "when": 1779398164637, + "tag": "0211_breezy_cloak", + "breakpoints": true } ] } diff --git a/packages/db/schema.ts b/packages/db/schema.ts index b133125fc3..06f54620d2 100644 --- a/packages/db/schema.ts +++ b/packages/db/schema.ts @@ -567,6 +567,9 @@ export const workflowSchedule = pgTable( archivedAtPartialIdx: index('workflow_schedule_archived_at_partial_idx') .on(table.archivedAt) .where(sql`${table.archivedAt} IS NOT NULL`), + sourceWorkspaceSourceTypeIdx: index( + 'idx_workflow_schedule_on_source_workspace_id_source_t_c07f3bba6' + ).on(table.sourceWorkspaceId, table.sourceType, table.archivedAt, table.status), } } ) @@ -651,6 +654,14 @@ export const webhook = pgTable( archivedAtPartialIdx: index('webhook_archived_at_partial_idx') .on(table.archivedAt) .where(sql`${table.archivedAt} IS NOT NULL`), + providerActiveWorkflowDeploymentIdx: index( + 'idx_webhook_on_provider_is_active_workflow_id_deploym_bdeed5468' + ).on(table.provider, table.isActive, table.workflowId, table.deploymentVersionId), + workflowBlockUpdatedDescIdx: index('idx_webhook_on_workflow_id_block_id_updated_at_desc').on( + table.workflowId, + table.blockId, + table.updatedAt.desc() + ), } } ) @@ -951,6 +962,10 @@ export const chat = pgTable( archivedAtPartialIdx: index('chat_archived_at_partial_idx') .on(table.archivedAt) .where(sql`${table.archivedAt} IS NOT NULL`), + workflowArchivedAtIdx: index('idx_chat_on_workflow_id_archived_at').on( + table.workflowId, + table.archivedAt + ), } } ) From 47bd7fa34586d432938fd7408e9be4b34dba2c20 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Thu, 21 May 2026 16:16:03 -0700 Subject: [PATCH 6/7] fix(logs-cleanup): listing active workspaces into mem + download time streaming lims (#4692) * fix(logs-cleanup): listing active workspaces into mem + download time streaming lims * fix * fix ci * address comments * add skill * fix client-server sep * fix parse bytes enforcement * address comments, antipatterns * address slides ssrf comment * more fixes * fix tests * fix --- .agents/skills/memory-load-check/SKILL.md | 138 ++++++ .agents/skills/validate-integration/SKILL.md | 15 +- .gitignore | 3 + apps/sim/app/api/files/parse/route.test.ts | 235 ++++++++- apps/sim/app/api/files/parse/route.ts | 455 ++++++++++++++---- apps/sim/app/api/files/upload/route.test.ts | 99 +++- apps/sim/app/api/files/upload/route.ts | 30 +- .../api/table/[tableId]/import/route.test.ts | 36 ++ .../app/api/table/[tableId]/import/route.ts | 32 +- .../app/api/table/import-csv/route.test.ts | 104 ++++ apps/sim/app/api/table/import-csv/route.ts | 32 +- apps/sim/app/api/tools/docusign/route.ts | 302 +++++++++--- .../export-presentation/route.ts | 176 +++++++ apps/sim/app/api/tools/image/route.ts | 108 ++++- apps/sim/app/api/tools/tts/route.ts | 16 +- apps/sim/app/api/tools/tts/unified/route.ts | 123 +++-- .../sim/app/api/tools/typeform/files/route.ts | 168 +++++++ apps/sim/app/api/tools/video/route.ts | 171 +++++-- apps/sim/app/api/v1/files/route.ts | 27 +- apps/sim/background/cleanup-logs.ts | 12 +- apps/sim/blocks/blocks/docusign.ts | 1 + apps/sim/blocks/blocks/file.test.ts | 52 ++ apps/sim/blocks/blocks/file.ts | 26 +- apps/sim/blocks/blocks/google_slides.ts | 1 + apps/sim/blocks/blocks/typeform.ts | 1 + .../sim/lib/api/contracts/storage-transfer.ts | 4 +- apps/sim/lib/api/contracts/tools/google.ts | 35 ++ apps/sim/lib/api/contracts/tools/index.ts | 1 + apps/sim/lib/api/contracts/tools/typeform.ts | 22 + apps/sim/lib/billing/cleanup-dispatcher.ts | 218 ++++++--- .../core/security/input-validation.server.ts | 42 +- apps/sim/lib/core/utils/stream-limits.test.ts | 232 +++++++++ apps/sim/lib/core/utils/stream-limits.ts | 321 ++++++++++++ .../payloads/materialization.server.ts | 27 +- apps/sim/lib/execution/payloads/store.test.ts | 82 ++++ apps/sim/lib/logs/execution/logger.test.ts | 91 ++++ apps/sim/lib/logs/execution/logger.ts | 438 +++++++++++++++-- apps/sim/lib/logs/types.ts | 12 + apps/sim/lib/table/import.ts | 4 +- apps/sim/lib/table/service.ts | 8 +- .../contexts/copilot/copilot-file-manager.ts | 52 ++ .../sim/lib/uploads/contexts/copilot/index.ts | 2 + .../execution/execution-file-manager.ts | 17 +- apps/sim/lib/uploads/core/storage-service.ts | 20 +- .../lib/uploads/providers/blob/client.test.ts | 19 + apps/sim/lib/uploads/providers/blob/client.ts | 56 ++- .../lib/uploads/providers/s3/client.test.ts | 25 +- apps/sim/lib/uploads/providers/s3/client.ts | 35 +- apps/sim/lib/uploads/shared/types.ts | 1 + .../lib/uploads/utils/file-utils.server.ts | 30 +- .../tools/docusign/download_document.test.ts | 59 +++ apps/sim/tools/docusign/download_document.ts | 39 +- apps/sim/tools/docusign/types.ts | 5 +- apps/sim/tools/file/parser.test.ts | 117 +++++ apps/sim/tools/file/parser.ts | 55 ++- .../google_slides/export_presentation.test.ts | 223 +++++++++ .../google_slides/export_presentation.ts | 96 ++-- apps/sim/tools/http/request.test.ts | 14 + apps/sim/tools/http/request.ts | 12 +- apps/sim/tools/index.test.ts | 82 ++++ apps/sim/tools/index.ts | 177 ++++++- apps/sim/tools/typeform/files.test.ts | 241 ++++++++++ apps/sim/tools/typeform/files.ts | 86 +--- apps/sim/tools/typeform/types.ts | 4 +- scripts/check-api-validation-contracts.ts | 4 +- 65 files changed, 4726 insertions(+), 645 deletions(-) create mode 100644 .agents/skills/memory-load-check/SKILL.md create mode 100644 apps/sim/app/api/table/import-csv/route.test.ts create mode 100644 apps/sim/app/api/tools/google_slides/export-presentation/route.ts create mode 100644 apps/sim/app/api/tools/typeform/files/route.ts create mode 100644 apps/sim/blocks/blocks/file.test.ts create mode 100644 apps/sim/lib/api/contracts/tools/typeform.ts create mode 100644 apps/sim/lib/core/utils/stream-limits.test.ts create mode 100644 apps/sim/lib/core/utils/stream-limits.ts create mode 100644 apps/sim/tools/docusign/download_document.test.ts create mode 100644 apps/sim/tools/file/parser.test.ts create mode 100644 apps/sim/tools/google_slides/export_presentation.test.ts create mode 100644 apps/sim/tools/typeform/files.test.ts diff --git a/.agents/skills/memory-load-check/SKILL.md b/.agents/skills/memory-load-check/SKILL.md new file mode 100644 index 0000000000..5a46fce78c --- /dev/null +++ b/.agents/skills/memory-load-check/SKILL.md @@ -0,0 +1,138 @@ +--- +name: memory-load-check +description: Review PRs and diffs for unbounded memory loading, concurrency explosions, oversized payload materialization, and missing pagination or byte caps. Use when reviewing cleanup jobs, background jobs, data imports/exports, file parsing, API fan-out, workflow execution payloads, large arrays/files, or any change that reads many rows, files, responses, logs, or external API pages into process memory. +--- + +# Memory Load Check + +Use this skill when a PR or diff could load unbounded data into a Node/Bun process, especially in cron routes, background tasks, API routes, workflow execution, file parsing, cleanup jobs, migrations, import/export flows, and external API integrations. + +## Review Goal + +Prove each changed path has explicit bounds for: +- rows held in memory +- bytes held in memory +- concurrent promises, DB queries, HTTP calls, storage operations, and jobs +- number of pages, batches, chunks, retries, and retained intermediate objects + +If any bound depends only on current production size or "probably small" data, treat it as a finding. + +## References + +Read these when doing a deeper pass: +- Node.js streams/backpressure: https://nodejs.org/learn/modules/backpressuring-in-streams +- Node.js stream usage: https://nodejs.org/en/learn/modules/how-to-use-streams +- Keyset/cursor pagination over offset scans: https://blog.sequinstream.com/keyset-cursors-not-offsets-for-postgres-pagination/ +- Postgres pagination tradeoffs: https://www.citusdata.com/blog/2016/03/30/five-ways-to-paginate/ + +## Sim Helpers To Prefer + +- `apps/sim/lib/cleanup/batch-delete.ts` + - `chunkedBatchDelete`: bounded SELECT -> optional side effect -> DELETE loop. + - `batchDeleteByWorkspaceAndTimestamp`: common workspace/timestamp cleanup wrapper. + - `selectRowsByIdChunks`: chunks large ID sets and enforces an overall row cap. + - `chunkArray`: use only after the input set itself is already bounded. +- `apps/sim/lib/core/utils/stream-limits.ts` + - `PayloadSizeLimitError` + - `assertKnownSizeWithinLimit` + - `assertContentLengthWithinLimit` + - `readStreamToBufferWithLimit` + - `readNodeStreamToBufferWithLimit` + - `readResponseToBufferWithLimit` + - `readResponseTextWithLimit` +- Cleanup dispatcher pattern in `apps/sim/lib/billing/cleanup-dispatcher.ts` + - page active workspaces with `WHERE id > afterId ORDER BY id LIMIT N` + - dispatch concrete chunks (`workspaceIds`, retention, label) instead of one giant scope + - prefer Trigger.dev queue/concurrency keys when available + - execute inline fallback chunks sequentially, not with unbounded `Promise.all` +- File parse route pattern in `apps/sim/app/api/files/parse/route.ts` + - cap downloads and parsed output separately + - preserve partial results when a later item exceeds the cap + - never read untrusted response bodies without a byte cap +- Large workflow value payloads + - prefer durable references/manifests over inlining large arrays or files + - materialize refs only behind an explicit byte budget + +## Review Workflow + +1. Identify every changed data source: + - database queries + - storage lists/downloads/uploads + - external API pagination + - file reads and HTTP responses + - workflow logs, snapshots, payloads, arrays, and manifests + - queues, cron routes, and background jobs +2. For each source, write down the maximum cardinality and maximum bytes. If the code does not enforce one, it is unbounded. +3. Trace whether data is processed incrementally or accumulated: + - arrays from `select`, `findMany`, `Promise.all`, `map`, `filter`, `flatMap` + - maps/sets keyed by all users, workspaces, executions, files, or rows + - `Buffer.concat`, `response.arrayBuffer()`, `response.text()`, `JSON.stringify`, `JSON.parse` + - queues of promises or job payloads built before dispatch +4. Check concurrency separately from memory: + - no `Promise.all(items.map(...))` unless `items` is already small and bounded + - use chunks, sequential loops, queue concurrency, or a concurrency limiter + - align concurrency with DB pool size, storage/API limits, and task queue semantics +5. Verify SQL shape: + - every bulk query has `LIMIT` + - large pagination uses cursor/keyset style (`id > afterId`, timestamps plus unique ID), not deep `OFFSET` + - `IN (...)` lists are chunked + - side-effect rows selected before delete have per-batch and per-run caps +6. Verify byte safety: + - check `Content-Length` when available + - stream with cumulative byte accounting + - cap both input bytes and expanded output bytes + - reject or reference oversized values before serializing large JSON responses +7. Confirm failure behavior: + - exceeding a cap should stop before loading more data + - partial successful work should be preserved when the API contract expects it + - retries should not duplicate huge in-memory state + - cleanup jobs should make progress over future runs instead of widening one run + +## Red Flags + +- loads all active workspaces, users, executions, logs, files, messages, or subscriptions before filtering +- builds a full `Map` or `Set` for a platform-wide scope +- uses `Promise.all` over rows from an unbounded query +- fetches all pages from an external API before processing +- reads an entire file, HTTP response, or stream without a max byte budget +- checks size only after `Buffer.concat`, `arrayBuffer`, `text`, `JSON.parse`, or parse expansion +- chunks only after loading the complete dataset +- paginates with unbounded/deep `OFFSET` on a mutable or large table +- creates one queue job per row without batching or a queue-level concurrency key +- accumulates per-row errors/results with no maximum +- adds a cache, singleton, or module-level collection without eviction or size limits + +## Preferred Fixes + +- Move filters into SQL/API requests and select only needed columns. +- Replace full-table loads with cursor/keyset pagination and a deterministic order. +- Process one page/batch at a time; do not keep previous pages unless needed. +- Add per-batch and per-run row caps so long backlogs drain across repeated jobs. +- Split large ID lists with `selectRowsByIdChunks` or `chunkArray` after bounding the source. +- Use `chunkedBatchDelete` for cleanup loops with row side effects. +- Use stream-limit helpers for file/HTTP/body reads. +- Store large workflow values as refs/manifests and materialize only within a caller budget. +- Replace unbounded `Promise.all` with sequential chunk loops, queue concurrency, or a small limiter. +- Include tests that prove caps stop work early and partial results or progress are preserved. + +## Findings Format + +Lead with concrete findings, ordered by risk: + +```markdown +## Findings + +- **P1 Unbounded workspace load in cleanup dispatch** (`path/to/file.ts`) + The new path calls `select().from(workspace)` without a limit, then builds maps for every row before dispatch. In production this scales with all active workspaces and can exhaust the app process. Page by `workspace.id` with a fixed limit and dispatch bounded chunks. + +## Good Signals + +- Uses `readResponseToBufferWithLimit` for external downloads. +- Inline fallback processes chunks sequentially. + +## Residual Risk + +- The row cap is explicit, but no test currently proves the loop stops at the cap. +``` + +Only say "good to go" when every changed source has explicit row, byte, and concurrency bounds or the boundedness is proven by a stable invariant. diff --git a/.agents/skills/validate-integration/SKILL.md b/.agents/skills/validate-integration/SKILL.md index d8d243c501..7a5ea8e7ca 100644 --- a/.agents/skills/validate-integration/SKILL.md +++ b/.agents/skills/validate-integration/SKILL.md @@ -232,13 +232,23 @@ If any tools support pagination: - [ ] Pagination response fields (`nextToken`, `cursor`, etc.) are included in tool outputs - [ ] Pagination subBlocks are set to `mode: 'advanced'` -## Step 7: Validate Error Handling +## Step 7: Validate Memory Load Safety + +If any tool lists, searches, exports, imports, downloads, uploads, paginates, batches, transforms arrays, or reads file/HTTP bodies, read `.agents/skills/memory-load-check/SKILL.md` and apply it to the integration. + +- [ ] List/search tools expose API limits and do not auto-fetch every page into memory +- [ ] Transform logic does not build unbounded arrays, maps, sets, or `Promise.all` fan-outs +- [ ] File and HTTP body reads use explicit byte caps or existing stream-limit helpers +- [ ] Large result payloads are summarized, paginated, referenced, or capped rather than raw-dumped +- [ ] Pagination and download tests cover caps, early stop behavior, or partial-result preservation when relevant + +## Step 8: Validate Error Handling - [ ] `transformResponse` checks for error conditions before accessing data - [ ] Error responses include meaningful messages (not just generic "failed") - [ ] HTTP error status codes are handled (check `response.ok` or status codes) -## Step 8: Report and Fix +## Step 9: Report and Fix ### Report Format @@ -297,6 +307,7 @@ After fixing, confirm: - [ ] Validated OAuth scopes use centralized utilities (getScopesForService, getCanonicalScopesForProvider) — no hardcoded arrays - [ ] Validated scope descriptions exist in `SCOPE_DESCRIPTIONS` within `lib/oauth/utils.ts` for all scopes - [ ] Validated pagination consistency across tools and block +- [ ] Validated memory load safety using `.agents/skills/memory-load-check/SKILL.md` when tools list/search/download/import/export/batch data - [ ] Validated error handling (error checks, meaningful messages) - [ ] Validated registry entries (tools and block, alphabetical, correct imports) - [ ] Reported all issues grouped by severity diff --git a/.gitignore b/.gitignore index c0532fd449..c38b288a68 100644 --- a/.gitignore +++ b/.gitignore @@ -85,3 +85,6 @@ i18n.cache .claude/worktrees/ .claude/scheduled_tasks.lock .deepsec/ + +# Personal Cursor Skills +.cursor/skills/ask-sim/ diff --git a/apps/sim/app/api/files/parse/route.test.ts b/apps/sim/app/api/files/parse/route.test.ts index 8c18422bae..eb421bf450 100644 --- a/apps/sim/app/api/files/parse/route.test.ts +++ b/apps/sim/app/api/files/parse/route.test.ts @@ -31,6 +31,9 @@ const { mockFsWriteFile, mockJoin, actualPath, + mockFileExistsInWorkspace, + mockListWorkspaceFiles, + mockUploadWorkspaceFile, } = vi.hoisted(() => { // eslint-disable-next-line @typescript-eslint/no-require-imports const actualPath = require('path') as typeof import('path') @@ -49,7 +52,7 @@ const { metadata: { pageCount: 1 }, }), mockFsAccess: vi.fn().mockResolvedValue(undefined), - mockFsStat: vi.fn().mockImplementation(() => ({ isFile: () => true })), + mockFsStat: vi.fn().mockImplementation(() => ({ isFile: () => true, size: 17 })), mockFsReadFile: vi.fn().mockResolvedValue(Buffer.from('test file content')), mockFsWriteFile: vi.fn().mockResolvedValue(undefined), mockJoin: vi.fn((...args: string[]): string => { @@ -59,6 +62,9 @@ const { return actualPath.join(...args) }), actualPath, + mockFileExistsInWorkspace: vi.fn().mockResolvedValue(false), + mockListWorkspaceFiles: vi.fn().mockResolvedValue([]), + mockUploadWorkspaceFile: vi.fn().mockResolvedValue({}), } }) @@ -104,6 +110,12 @@ vi.mock('@/lib/uploads/contexts/execution', () => ({ uploadExecutionFile: vi.fn(), })) +vi.mock('@/lib/uploads/contexts/workspace', () => ({ + fileExistsInWorkspace: mockFileExistsInWorkspace, + listWorkspaceFiles: mockListWorkspaceFiles, + uploadWorkspaceFile: mockUploadWorkspaceFile, +})) + vi.mock('@/lib/uploads/server/metadata', () => ({ getFileMetadataByKey: vi.fn(), })) @@ -175,7 +187,12 @@ describe('File Parse API Route', () => { permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue({ canView: true }) storageServiceMockFns.mockHasCloudStorage.mockReturnValue(true) storageServiceMockFns.mockDownloadFile.mockResolvedValue(Buffer.from('test file content')) + mockFsStat.mockResolvedValue({ isFile: () => true, size: 17 }) + mockFsReadFile.mockResolvedValue(Buffer.from('test file content')) mockIsSupportedFileType.mockReturnValue(true) + mockFileExistsInWorkspace.mockResolvedValue(false) + mockListWorkspaceFiles.mockResolvedValue([]) + mockUploadWorkspaceFile.mockResolvedValue({}) mockParseFile.mockResolvedValue({ content: 'parsed content', metadata: { pageCount: 1 }, @@ -311,6 +328,170 @@ describe('File Parse API Route', () => { expect(data.results).toHaveLength(2) }) + it('should keep the multi-file download cap independent from the remaining parsed-output cap', async () => { + inputValidationMockFns.mockValidateUrlWithDNS.mockResolvedValue({ + isValid: true, + resolvedIP: '203.0.113.10', + }) + inputValidationMockFns.mockSecureFetchWithPinnedIP + .mockResolvedValueOnce( + new Response('file content', { + status: 200, + headers: { 'content-type': 'text/plain' }, + }) + ) + .mockResolvedValueOnce( + new Response('second file content', { + status: 200, + headers: { + 'content-length': String(20 * 1024 * 1024), + 'content-type': 'text/plain', + }, + }) + ) + + const fourMbContent = 'a'.repeat(4 * 1024 * 1024) + mockParseBuffer + .mockResolvedValueOnce({ + content: fourMbContent, + metadata: { pageCount: 1 }, + }) + .mockResolvedValueOnce({ + content: 'second file', + metadata: { pageCount: 1 }, + }) + + const req = createMockRequest('POST', { + filePath: ['https://example.com/file1.txt', 'https://example.com/file2.txt'], + }) + + const response = await POST(req) + const data = await response.json() + + expect(response.status).toBe(200) + expect(data.results).toHaveLength(2) + expect(inputValidationMockFns.mockSecureFetchWithPinnedIP).toHaveBeenNthCalledWith( + 1, + 'https://example.com/file1.txt', + '203.0.113.10', + expect.objectContaining({ maxResponseBytes: 100 * 1024 * 1024 }) + ) + expect(inputValidationMockFns.mockSecureFetchWithPinnedIP).toHaveBeenNthCalledWith( + 2, + 'https://example.com/file2.txt', + '203.0.113.10', + expect.objectContaining({ maxResponseBytes: 100 * 1024 * 1024 }) + ) + }) + + it('should preserve the full download cap when an external URL reuses a workspace file', async () => { + inputValidationMockFns.mockValidateUrlWithDNS.mockResolvedValue({ + isValid: true, + resolvedIP: '203.0.113.10', + }) + inputValidationMockFns.mockSecureFetchWithPinnedIP.mockResolvedValue( + new Response('file content', { + status: 200, + headers: { 'content-type': 'text/plain' }, + }) + ) + mockFileExistsInWorkspace.mockResolvedValueOnce(false).mockResolvedValueOnce(true) + mockListWorkspaceFiles.mockResolvedValueOnce([ + { name: 'file2.txt', key: 'workspace-file2.txt' }, + ]) + + mockParseBuffer + .mockResolvedValueOnce({ + content: 'a'.repeat(4 * 1024 * 1024), + metadata: { pageCount: 1 }, + }) + .mockResolvedValueOnce({ + content: 'second file', + metadata: { pageCount: 1 }, + }) + + const req = createMockRequest('POST', { + filePath: ['https://example.com/file1.txt', 'https://example.com/file2.txt'], + workspaceId: 'workspace-id', + }) + + const response = await POST(req) + const data = await response.json() + + expect(response.status).toBe(200) + expect(data.results).toHaveLength(2) + expect(storageServiceMockFns.mockDownloadFile).toHaveBeenCalledWith( + expect.objectContaining({ key: 'workspace-file2.txt', maxBytes: 100 * 1024 * 1024 }) + ) + }) + + it('should stop multi-file parsing once the combined parsed output is too large', async () => { + inputValidationMockFns.mockValidateUrlWithDNS.mockResolvedValue({ + isValid: true, + resolvedIP: '203.0.113.10', + }) + inputValidationMockFns.mockSecureFetchWithPinnedIP.mockResolvedValue( + new Response('file content', { + status: 200, + headers: { 'content-type': 'text/plain' }, + }) + ) + + mockParseBuffer.mockResolvedValueOnce({ + content: 'a'.repeat(5 * 1024 * 1024 + 1), + metadata: { pageCount: 1 }, + }) + + const req = createMockRequest('POST', { + filePath: ['https://example.com/file1.txt', 'https://example.com/file2.txt'], + }) + + const response = await POST(req) + const data = await response.json() + + expect(response.status).toBe(413) + expect(data.success).toBe(false) + expect(data.error).toContain('too large') + expect(inputValidationMockFns.mockSecureFetchWithPinnedIP).toHaveBeenCalledTimes(1) + }) + + it('should include successful multi-file parse results when a later file exceeds the cap', async () => { + inputValidationMockFns.mockValidateUrlWithDNS.mockResolvedValue({ + isValid: true, + resolvedIP: '203.0.113.10', + }) + inputValidationMockFns.mockSecureFetchWithPinnedIP.mockResolvedValue( + new Response('file content', { + status: 200, + headers: { 'content-type': 'text/plain' }, + }) + ) + + mockParseBuffer + .mockResolvedValueOnce({ + content: 'first file', + metadata: { pageCount: 1 }, + }) + .mockResolvedValueOnce({ + content: 'a'.repeat(5 * 1024 * 1024), + metadata: { pageCount: 1 }, + }) + + const req = createMockRequest('POST', { + filePath: ['https://example.com/file1.txt', 'https://example.com/file2.txt'], + }) + + const response = await POST(req) + const data = await response.json() + + expect(response.status).toBe(200) + expect(data.success).toBe(true) + expect(data.error).toContain('too large') + expect(data.results).toHaveLength(1) + expect(data.results[0].output.content).toBe('first file') + expect(inputValidationMockFns.mockSecureFetchWithPinnedIP).toHaveBeenCalledTimes(2) + }) + it('should pass custom headers when fetching external URLs', async () => { inputValidationMockFns.mockValidateUrlWithDNS.mockResolvedValue({ isValid: true, @@ -344,6 +525,58 @@ describe('File Parse API Route', () => { ) }) + it('should reject oversized external downloads before reading the body', async () => { + inputValidationMockFns.mockValidateUrlWithDNS.mockResolvedValue({ + isValid: true, + resolvedIP: '203.0.113.10', + }) + inputValidationMockFns.mockSecureFetchWithPinnedIP.mockResolvedValue( + new Response('oversized', { + status: 200, + headers: { 'content-length': '104857601', 'content-type': 'text/plain' }, + }) + ) + + const req = createMockRequest('POST', { + filePath: 'https://example.com/large.txt', + }) + + const response = await POST(req) + const data = await response.json() + + expect(response.status).toBe(200) + expect(data.success).toBe(false) + expect(data.error).toContain('too large') + expect(inputValidationMockFns.mockSecureFetchWithPinnedIP).toHaveBeenCalledWith( + 'https://example.com/large.txt', + '203.0.113.10', + expect.objectContaining({ + maxResponseBytes: 104857600, + }) + ) + }) + + it('should reject oversized local files before materializing them', async () => { + setupFileApiMocks({ + cloudEnabled: false, + storageProvider: 'local', + authenticated: true, + }) + mockFsStat.mockResolvedValue({ isFile: () => true, size: 104857601 }) + + const req = createMockRequest('POST', { + filePath: 'workspace/large.txt', + }) + + const response = await POST(req) + const data = await response.json() + + expect(response.status).toBe(200) + expect(data.success).toBe(false) + expect(data.error).toContain('too large') + expect(mockFsReadFile).not.toHaveBeenCalled() + }) + it('should process execution file URLs with context query param', async () => { setupFileApiMocks({ cloudEnabled: true, diff --git a/apps/sim/app/api/files/parse/route.ts b/apps/sim/app/api/files/parse/route.ts index aa3c3ff93c..d015bc1cf6 100644 --- a/apps/sim/app/api/files/parse/route.ts +++ b/apps/sim/app/api/files/parse/route.ts @@ -1,6 +1,6 @@ import { Buffer, isUtf8 } from 'buffer' import { createHash } from 'crypto' -import fsPromises, { readFile } from 'fs/promises' +import fsPromises from 'fs/promises' import path from 'path' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' @@ -15,6 +15,13 @@ import { validateUrlWithDNS, } from '@/lib/core/security/input-validation.server' import { sanitizeUrlForLog } from '@/lib/core/utils/logging' +import { + assertKnownSizeWithinLimit, + DEFAULT_MAX_ERROR_BODY_BYTES, + isPayloadSizeLimitError, + readResponseTextWithLimit, + readResponseToBufferWithLimit, +} from '@/lib/core/utils/stream-limits' import { isSupportedFileType, parseFile } from '@/lib/file-parsers' import { isUsingCloudStorage, type StorageContext, StorageService } from '@/lib/uploads' import { uploadExecutionFile } from '@/lib/uploads/contexts/execution' @@ -41,6 +48,8 @@ const logger = createLogger('FilesParseAPI') const MAX_DOWNLOAD_SIZE_BYTES = 100 * 1024 * 1024 // 100 MB const DOWNLOAD_TIMEOUT_MS = 30000 // 30 seconds +const MAX_FILE_REFERENCE_LENGTH = 4096 +const MAX_MULTI_FILE_PARSE_OUTPUT_BYTES = 5 * 1024 * 1024 const BINARY_EXTENSIONS = new Set(binaryExtensionsList) function isLikelyTextBuffer(fileBuffer: Buffer): boolean { @@ -69,6 +78,10 @@ interface ParseResult { } } +function getContentBytes(content: unknown): number { + return typeof content === 'string' ? Buffer.byteLength(content, 'utf8') : 0 +} + /** * Main API route handler */ @@ -99,15 +112,17 @@ export const POST = withRouteHandler(async (request: NextRequest) => { request, {}, { - validationErrorResponse: (error) => - NextResponse.json( + validationErrorResponse: (error) => { + const message = getValidationErrorMessage(error, 'Invalid request data') + return NextResponse.json( { success: false, - error: getValidationErrorMessage(error, 'Invalid request data'), + error: message, filePath: '', }, - { status: 400 } - ), + { status: message.includes('At most 10 files') ? 413 : 400 } + ) + }, } ) if (!parsed.success) return parsed.response @@ -134,49 +149,69 @@ export const POST = withRouteHandler(async (request: NextRequest) => { }) if (Array.isArray(filePath)) { - const results = await Promise.all( - filePath.map(async (singlePath) => { - if (!singlePath || (typeof singlePath === 'string' && singlePath.trim() === '')) { - return { - success: false, - error: 'Empty file path in array', - filePath: singlePath || '', - } - } + const results = [] + let totalOutputBytes = 0 + + for (const singlePath of filePath) { + if (!singlePath || (typeof singlePath === 'string' && singlePath.trim() === '')) { + results.push({ + success: false, + error: 'Empty file path in array', + filePath: singlePath || '', + }) + continue + } - const result = await parseFileSingle( - singlePath, - fileType, - workspaceId, - userId, - executionContext, - headers - ) - if (result.metadata) { - result.metadata.processingTime = Date.now() - startTime - } + const remainingOutputBytes = MAX_MULTI_FILE_PARSE_OUTPUT_BYTES - totalOutputBytes + if (remainingOutputBytes <= 0) { + return parsedOutputTooLargeResponse(results) + } - if (result.success) { - const displayName = - result.originalName || extractCleanFilename(result.filePath) || 'unknown' - return { - success: true, - output: { - content: result.content, - name: displayName, - fileType: result.metadata?.fileType || 'application/octet-stream', - size: result.metadata?.size || 0, - binary: false, - file: result.userFile, - }, - filePath: result.filePath, - viewerUrl: result.viewerUrl, - } + const result = await parseFileSingle( + singlePath, + fileType, + workspaceId, + userId, + executionContext, + headers, + request.signal, + MAX_DOWNLOAD_SIZE_BYTES, + remainingOutputBytes + ) + if (result.metadata) { + result.metadata.processingTime = Date.now() - startTime + } + + if (result.success) { + totalOutputBytes += getContentBytes(result.content) + if (totalOutputBytes > MAX_MULTI_FILE_PARSE_OUTPUT_BYTES) { + return parsedOutputTooLargeResponse(results) } - return result - }) - ) + const displayName = + result.originalName || extractCleanFilename(result.filePath) || 'unknown' + results.push({ + success: true, + output: { + content: result.content, + name: displayName, + fileType: result.metadata?.fileType || 'application/octet-stream', + size: result.metadata?.size || 0, + binary: false, + file: result.userFile, + }, + filePath: result.filePath, + viewerUrl: result.viewerUrl, + }) + continue + } + + if (result.error?.startsWith('Parsed file output is too large')) { + return parsedOutputTooLargeResponse(results) + } + + results.push(result) + } return NextResponse.json({ success: true, @@ -190,7 +225,8 @@ export const POST = withRouteHandler(async (request: NextRequest) => { workspaceId, userId, executionContext, - headers + headers, + request.signal ) if (result.metadata) { @@ -237,7 +273,10 @@ async function parseFileSingle( workspaceId: string, userId: string, executionContext?: ExecutionContext, - headers?: Record + headers?: Record, + signal?: AbortSignal, + maxDownloadBytes = MAX_DOWNLOAD_SIZE_BYTES, + maxParsedOutputBytes?: number ): Promise { logger.info('Parsing file:', filePath) @@ -249,6 +288,15 @@ async function parseFileSingle( } } + const referenceValidation = validateFileReferenceShape(filePath) + if (!referenceValidation.isValid) { + return { + success: false, + error: referenceValidation.error || 'Invalid file reference', + filePath, + } + } + const pathValidation = validateFilePath(filePath) if (!pathValidation.isValid) { return { @@ -259,18 +307,122 @@ async function parseFileSingle( } if (isInternalFileUrl(filePath)) { - return handleCloudFile(filePath, fileType, undefined, userId, executionContext) + return handleCloudFile( + filePath, + fileType, + undefined, + userId, + executionContext, + maxDownloadBytes, + maxParsedOutputBytes + ) } if (filePath.startsWith('http://') || filePath.startsWith('https://')) { - return handleExternalUrl(filePath, fileType, workspaceId, userId, executionContext, headers) + return handleExternalUrl( + filePath, + fileType, + workspaceId, + userId, + executionContext, + headers, + signal, + maxDownloadBytes, + maxParsedOutputBytes + ) } if (isUsingCloudStorage()) { - return handleCloudFile(filePath, fileType, undefined, userId, executionContext) + return handleCloudFile( + filePath, + fileType, + undefined, + userId, + executionContext, + maxDownloadBytes, + maxParsedOutputBytes + ) } - return handleLocalFile(filePath, fileType, userId, executionContext) + return handleLocalFile( + filePath, + fileType, + userId, + executionContext, + maxDownloadBytes, + maxParsedOutputBytes + ) +} + +function validateFileReferenceShape(filePath: string): { isValid: boolean; error?: string } { + const trimmed = filePath.trim() + if ( + trimmed.startsWith('http://') || + trimmed.startsWith('https://') || + isInternalFileUrl(trimmed) + ) { + return { isValid: true } + } + + if (trimmed.startsWith('data:')) { + return { + isValid: false, + error: 'File input must be a URL or uploaded file reference, not inline file content', + } + } + + if (filePath.length > MAX_FILE_REFERENCE_LENGTH) { + return { + isValid: false, + error: 'File reference is too long; provide a file URL or upload the file instead', + } + } + + if (/[\x00-\x08\x0B\x0C\x0E-\x1F]/.test(filePath)) { + return { + isValid: false, + error: + 'File reference contains binary content; provide a file URL or upload the file instead', + } + } + + const newlineCount = filePath.match(/\r\n|\r|\n/g)?.length ?? 0 + if (newlineCount > 2) { + return { + isValid: false, + error: + 'File reference looks like inline file content; provide a file URL or upload the file instead', + } + } + + return { isValid: true } +} + +function parsedOutputTooLargeResponse(results?: unknown[]): NextResponse { + const hasPartialResults = Boolean(results && results.length > 0) + return NextResponse.json( + { + success: hasPartialResults, + error: `Parsed file output is too large to return safely. Maximum combined parsed output is ${prettySize( + MAX_MULTI_FILE_PARSE_OUTPUT_BYTES + )}.`, + ...(results && results.length > 0 ? { results } : {}), + }, + { status: hasPartialResults ? 200 : 413 } + ) +} + +function getParsedOutputTooLargeMessage(maxBytes: number): string { + return `Parsed file output is too large to return safely. Maximum parsed output is ${prettySize( + maxBytes + )}.` +} + +function assertParsedContentWithinLimit(content: string, maxBytes?: number): string { + if (maxBytes !== undefined) { + assertKnownSizeWithinLimit(Buffer.byteLength(content, 'utf8'), maxBytes, 'parsed file output') + } + return content } /** @@ -311,7 +463,10 @@ async function handleExternalUrl( workspaceId: string, userId: string, executionContext?: ExecutionContext, - headers?: Record + headers?: Record, + signal?: AbortSignal, + maxDownloadBytes = MAX_DOWNLOAD_SIZE_BYTES, + maxParsedOutputBytes?: number ): Promise { try { logger.info('Fetching external URL:', url) @@ -388,29 +543,39 @@ async function handleExternalUrl( if (existingFile) { const storageFilePath = `/api/files/serve/${existingFile.key}` - return handleCloudFile(storageFilePath, fileType, 'workspace', userId, executionContext) + return handleCloudFile( + storageFilePath, + fileType, + 'workspace', + userId, + executionContext, + maxDownloadBytes, + maxParsedOutputBytes + ) } } } const response = await secureFetchWithPinnedIP(url, urlValidation.resolvedIP!, { timeout: DOWNLOAD_TIMEOUT_MS, + maxResponseBytes: maxDownloadBytes, + signal, ...(headers && Object.keys(headers).length > 0 && { headers }), }) if (!response.ok) { + await readResponseTextWithLimit(response, { + maxBytes: DEFAULT_MAX_ERROR_BODY_BYTES, + label: 'file download error response', + signal, + }).catch(() => '') throw new Error(`Failed to fetch URL: ${response.status} ${response.statusText}`) } - const contentLength = response.headers.get('content-length') - if (contentLength && Number.parseInt(contentLength) > MAX_DOWNLOAD_SIZE_BYTES) { - throw new Error(`File too large: ${contentLength} bytes (max: ${MAX_DOWNLOAD_SIZE_BYTES})`) - } - - const buffer = Buffer.from(await response.arrayBuffer()) - - if (buffer.length > MAX_DOWNLOAD_SIZE_BYTES) { - throw new Error(`File too large: ${buffer.length} bytes (max: ${MAX_DOWNLOAD_SIZE_BYTES})`) - } + const buffer = await readResponseToBufferWithLimit(response, { + maxBytes: maxDownloadBytes, + label: 'file download', + signal, + }) logger.info(`Downloaded file from URL: ${url}, size: ${buffer.length} bytes`) @@ -449,13 +614,20 @@ async function handleExternalUrl( let parseResult: ParseResult if (extension === 'pdf') { - parseResult = await handlePdfBuffer(buffer, filename, fileType, url) + parseResult = await handlePdfBuffer(buffer, filename, fileType, url, maxParsedOutputBytes) } else if (extension === 'csv') { - parseResult = await handleCsvBuffer(buffer, filename, fileType, url) + parseResult = await handleCsvBuffer(buffer, filename, fileType, url, maxParsedOutputBytes) } else if (isSupportedFileType(extension)) { - parseResult = await handleGenericTextBuffer(buffer, filename, extension, fileType, url) + parseResult = await handleGenericTextBuffer( + buffer, + filename, + extension, + fileType, + url, + maxParsedOutputBytes + ) } else { - parseResult = handleGenericBuffer(buffer, filename, extension, fileType) + parseResult = handleGenericBuffer(buffer, filename, extension, fileType, maxParsedOutputBytes) } // Attach userFile to the result @@ -466,6 +638,25 @@ async function handleExternalUrl( return parseResult } catch (error) { logger.error(`Error handling external URL ${sanitizeUrlForLog(url)}:`, error) + if (isPayloadSizeLimitError(error)) { + logger.warn('Rejected oversized external file parse payload', { + maxBytes: error.maxBytes, + observedBytes: error.observedBytes, + label: error.label, + url: sanitizeUrlForLog(url), + }) + return { + success: false, + error: + error.label === 'parsed file output' + ? getParsedOutputTooLargeMessage(error.maxBytes) + : `File is too large to parse safely. Maximum supported download size is ${prettySize( + error.maxBytes + )}.`, + filePath: url, + } + } + return { success: false, error: `Error fetching URL: ${(error as Error).message}`, @@ -484,7 +675,9 @@ async function handleCloudFile( fileType: string, explicitContext: string | undefined, userId: string, - executionContext?: ExecutionContext + executionContext?: ExecutionContext, + maxDownloadBytes = MAX_DOWNLOAD_SIZE_BYTES, + maxParsedOutputBytes?: number ): Promise { try { const cloudKey = extractStorageKey(filePath) @@ -524,7 +717,11 @@ async function handleCloudFile( } } - const fileBuffer = await StorageService.downloadFile({ key: cloudKey, context }) + const fileBuffer = await StorageService.downloadFile({ + key: cloudKey, + context, + maxBytes: maxDownloadBytes, + }) logger.info( `Downloaded file from ${context} storage (${explicitContext ? 'explicit' : 'inferred'}): ${cloudKey}, size: ${fileBuffer.length} bytes` ) @@ -582,19 +779,38 @@ async function handleCloudFile( let parseResult: ParseResult if (extension === 'pdf') { - parseResult = await handlePdfBuffer(fileBuffer, filename, fileType, normalizedFilePath) + parseResult = await handlePdfBuffer( + fileBuffer, + filename, + fileType, + normalizedFilePath, + maxParsedOutputBytes + ) } else if (extension === 'csv') { - parseResult = await handleCsvBuffer(fileBuffer, filename, fileType, normalizedFilePath) + parseResult = await handleCsvBuffer( + fileBuffer, + filename, + fileType, + normalizedFilePath, + maxParsedOutputBytes + ) } else if (isSupportedFileType(extension)) { parseResult = await handleGenericTextBuffer( fileBuffer, filename, extension, fileType, - normalizedFilePath + normalizedFilePath, + maxParsedOutputBytes ) } else { - parseResult = handleGenericBuffer(fileBuffer, filename, extension, fileType) + parseResult = handleGenericBuffer( + fileBuffer, + filename, + extension, + fileType, + maxParsedOutputBytes + ) parseResult.filePath = normalizedFilePath } @@ -614,6 +830,25 @@ async function handleCloudFile( logger.error(`Error handling cloud file ${filePath}:`, error) const errorMessage = (error as Error).message + if (isPayloadSizeLimitError(error)) { + logger.warn('Rejected oversized cloud file parse payload', { + maxBytes: error.maxBytes, + observedBytes: error.observedBytes, + label: error.label, + filePath, + }) + return { + success: false, + error: + error.label === 'parsed file output' + ? getParsedOutputTooLargeMessage(error.maxBytes) + : `File is too large to parse safely. Maximum supported download size is ${prettySize( + error.maxBytes + )}.`, + filePath, + } + } + if (errorMessage.includes('Access denied') || errorMessage.includes('Forbidden')) { throw new Error(`Error accessing file from cloud storage: ${errorMessage}`) } @@ -633,14 +868,17 @@ async function handleLocalFile( filePath: string, fileType: string, userId: string, - executionContext?: ExecutionContext + executionContext?: ExecutionContext, + maxDownloadBytes = MAX_DOWNLOAD_SIZE_BYTES, + maxParsedOutputBytes?: number ): Promise { try { - const filename = filePath.split('/').pop() || filePath + const storageKey = isInternalFileUrl(filePath) ? extractStorageKey(filePath) : filePath + const filename = storageKey.split('/').pop() || storageKey - const context = inferContextFromKey(filename) + const context = inferContextFromKey(storageKey) const hasAccess = await verifyFileAccess( - filename, + storageKey, userId, undefined, // customConfig context, // context @@ -656,7 +894,7 @@ async function handleLocalFile( } } - const fullPath = path.join(UPLOAD_DIR_SERVER, filename) + const fullPath = path.join(UPLOAD_DIR_SERVER, storageKey) logger.info('Processing local file:', fullPath) @@ -666,10 +904,12 @@ async function handleLocalFile( throw new Error(`File not found: ${filename}`) } - const result = await parseFile(fullPath) - const stats = await fsPromises.stat(fullPath) - const fileBuffer = await readFile(fullPath) + assertKnownSizeWithinLimit(stats.size, maxDownloadBytes, 'local file') + + const result = await parseFile(fullPath) + const content = assertParsedContentWithinLimit(result.content, maxParsedOutputBytes) + const fileBuffer = await fsPromises.readFile(fullPath) const hash = createHash('md5').update(fileBuffer).digest('hex') const extension = path.extname(filename).toLowerCase().substring(1) @@ -694,7 +934,7 @@ async function handleLocalFile( return { success: true, - content: result.content, + content, filePath, userFile, metadata: { @@ -706,6 +946,25 @@ async function handleLocalFile( } } catch (error) { logger.error(`Error handling local file ${filePath}:`, error) + if (isPayloadSizeLimitError(error)) { + logger.warn('Rejected oversized local file parse payload', { + maxBytes: error.maxBytes, + observedBytes: error.observedBytes, + label: error.label, + filePath, + }) + return { + success: false, + error: + error.label === 'parsed file output' + ? getParsedOutputTooLargeMessage(error.maxBytes) + : `File is too large to parse safely. Maximum supported local file size is ${prettySize( + error.maxBytes + )}.`, + filePath, + } + } + return { success: false, error: `Error processing local file: ${(error as Error).message}`, @@ -721,7 +980,8 @@ async function handlePdfBuffer( fileBuffer: Buffer, filename: string, fileType?: string, - originalPath?: string + originalPath?: string, + maxParsedOutputBytes?: number ): Promise { try { logger.info(`Parsing PDF in memory: ${filename}`) @@ -731,10 +991,11 @@ async function handlePdfBuffer( const content = result.content || createPdfFallbackMessage(result.metadata?.pageCount || 0, fileBuffer.length, originalPath) + const limitedContent = assertParsedContentWithinLimit(content, maxParsedOutputBytes) return { success: true, - content, + content: limitedContent, filePath: originalPath || filename, metadata: { fileType: fileType || 'application/pdf', @@ -744,6 +1005,8 @@ async function handlePdfBuffer( }, } } catch (error) { + if (isPayloadSizeLimitError(error)) throw error + logger.error('Failed to parse PDF in memory:', error) const content = createPdfFailureMessage( @@ -774,7 +1037,8 @@ async function handleCsvBuffer( fileBuffer: Buffer, filename: string, fileType?: string, - originalPath?: string + originalPath?: string, + maxParsedOutputBytes?: number ): Promise { try { logger.info(`Parsing CSV in memory: ${filename}`) @@ -784,7 +1048,7 @@ async function handleCsvBuffer( return { success: true, - content: result.content, + content: assertParsedContentWithinLimit(result.content, maxParsedOutputBytes), filePath: originalPath || filename, metadata: { fileType: fileType || 'text/csv', @@ -794,6 +1058,8 @@ async function handleCsvBuffer( }, } } catch (error) { + if (isPayloadSizeLimitError(error)) throw error + logger.error('Failed to parse CSV in memory:', error) return { success: false, @@ -817,7 +1083,8 @@ async function handleGenericTextBuffer( filename: string, extension: string, fileType?: string, - originalPath?: string + originalPath?: string, + maxParsedOutputBytes?: number ): Promise { try { logger.info(`Parsing text file in memory: ${filename}`) @@ -830,7 +1097,7 @@ async function handleGenericTextBuffer( return { success: true, - content: result.content, + content: assertParsedContentWithinLimit(result.content, maxParsedOutputBytes), filePath: originalPath || filename, metadata: { fileType: fileType || getMimeTypeFromExtension(extension), @@ -841,14 +1108,17 @@ async function handleGenericTextBuffer( } } } catch (parserError) { + if (isPayloadSizeLimitError(parserError)) throw parserError + logger.warn('Specialized parser failed, falling back to generic parsing:', parserError) } const content = fileBuffer.toString('utf-8') + const limitedContent = assertParsedContentWithinLimit(content, maxParsedOutputBytes) return { success: true, - content, + content: limitedContent, filePath: originalPath || filename, metadata: { fileType: fileType || getMimeTypeFromExtension(extension), @@ -858,6 +1128,8 @@ async function handleGenericTextBuffer( }, } } catch (error) { + if (isPayloadSizeLimitError(error)) throw error + logger.error('Failed to parse text file in memory:', error) return { success: false, @@ -880,12 +1152,13 @@ function handleGenericBuffer( fileBuffer: Buffer, filename: string, extension: string, - fileType?: string + fileType?: string, + maxParsedOutputBytes?: number ): ParseResult { const normalizedExtension = extension.toLowerCase() const content = !BINARY_EXTENSIONS.has(normalizedExtension) && isLikelyTextBuffer(fileBuffer) - ? fileBuffer.toString('utf-8') + ? assertParsedContentWithinLimit(fileBuffer.toString('utf-8'), maxParsedOutputBytes) : `[Binary ${normalizedExtension.toUpperCase()} file - ${fileBuffer.length} bytes]` return { diff --git a/apps/sim/app/api/files/upload/route.test.ts b/apps/sim/app/api/files/upload/route.test.ts index cf80cbf9b0..356d993d61 100644 --- a/apps/sim/app/api/files/upload/route.test.ts +++ b/apps/sim/app/api/files/upload/route.test.ts @@ -90,6 +90,14 @@ vi.mock('@/lib/uploads', () => ({ vi.mock('@/lib/uploads/core/storage-service', () => storageServiceMock) +vi.mock('@/lib/uploads/shared/types', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + MAX_WORKSPACE_FORMDATA_FILE_SIZE: 1024, + } +}) + vi.mock('@/lib/uploads/setup.server', () => ({ UPLOAD_DIR_SERVER: '/tmp/test-uploads', })) @@ -179,6 +187,13 @@ describe('File Upload API Route', () => { return new File([content], name, { type }) } + const createUploadRequest = (formData: FormData): NextRequest => + new NextRequest('http://localhost:3000/api/files/upload', { + method: 'POST', + headers: { 'content-length': '1024' }, + body: formData, + }) + beforeEach(() => { vi.clearAllMocks() }) @@ -196,10 +211,7 @@ describe('File Upload API Route', () => { const mockFile = createMockFile() const formData = createMockFormData([mockFile]) - const req = new NextRequest('http://localhost:3000/api/files/upload', { - method: 'POST', - body: formData, - }) + const req = createUploadRequest(formData) const response = await POST(req) const data = await response.json() @@ -215,6 +227,26 @@ describe('File Upload API Route', () => { expect(uploadWorkspaceFile).toHaveBeenCalled() }) + it('should accept chunked multipart uploads without a content-length header', async () => { + setupFileApiMocks({ + cloudEnabled: false, + storageProvider: 'local', + }) + + const formData = createMockFormData([createMockFile()]) + const req = new NextRequest('http://localhost:3000/api/files/upload', { + method: 'POST', + body: formData, + }) + + expect(req.headers.get('content-length')).toBeNull() + + const response = await POST(req) + + expect(response.status).toBe(200) + expect(uploadWorkspaceFile).toHaveBeenCalled() + }) + it('should upload a file to S3 when in S3 mode', async () => { setupFileApiMocks({ cloudEnabled: true, @@ -224,10 +256,7 @@ describe('File Upload API Route', () => { const mockFile = createMockFile() const formData = createMockFormData([mockFile]) - const req = new NextRequest('http://localhost:3000/api/files/upload', { - method: 'POST', - body: formData, - }) + const req = createUploadRequest(formData) const response = await POST(req) const data = await response.json() @@ -253,10 +282,7 @@ describe('File Upload API Route', () => { const mockFile2 = createMockFile('file2.txt', 'text/plain') const formData = createMockFormData([mockFile1, mockFile2]) - const req = new NextRequest('http://localhost:3000/api/files/upload', { - method: 'POST', - body: formData, - }) + const req = createUploadRequest(formData) const response = await POST(req) const data = await response.json() @@ -266,15 +292,44 @@ describe('File Upload API Route', () => { expect(data).toBeDefined() }) + it('rejects oversized workspace uploads before materializing file contents', async () => { + setupFileApiMocks({ + cloudEnabled: false, + storageProvider: 'local', + }) + + const mockFile = createMockFile('large.txt', 'text/plain', 'x'.repeat(1025)) + const arrayBufferSpy = vi.spyOn(mockFile, 'arrayBuffer') + const formData = { + getAll: (name: string) => (name === 'file' ? [mockFile] : []), + get: (name: string) => { + if (name === 'context') return 'workspace' + if (name === 'workspaceId') return 'test-workspace-id' + return null + }, + } as unknown as FormData + + const req = { + formData: async () => formData, + } as unknown as NextRequest + + const response = await POST(req) + const data = await response.json() + + expect(response.status).toBe(413) + expect(data.error).toBe('PayloadSizeLimitError') + expect(data.message).toContain('File exceeds the server upload limit') + expect(data.message).toContain('Use direct upload for larger workspace files') + expect(arrayBufferSpy).not.toHaveBeenCalled() + expect(uploadWorkspaceFile).not.toHaveBeenCalled() + }) + it('should handle missing files', async () => { setupFileApiMocks() const formData = new FormData() - const req = new NextRequest('http://localhost:3000/api/files/upload', { - method: 'POST', - body: formData, - }) + const req = createUploadRequest(formData) const response = await POST(req) const data = await response.json() @@ -295,10 +350,7 @@ describe('File Upload API Route', () => { const mockFile = createMockFile() const formData = createMockFormData([mockFile]) - const req = new NextRequest('http://localhost:3000/api/files/upload', { - method: 'POST', - body: formData, - }) + const req = createUploadRequest(formData) const response = await POST(req) const data = await response.json() @@ -362,6 +414,7 @@ describe('File Upload Security Tests', () => { const req = new Request('http://localhost/api/files/upload', { method: 'POST', + headers: { 'content-length': '1024' }, body: formData, }) @@ -381,6 +434,7 @@ describe('File Upload Security Tests', () => { const req = new Request('http://localhost/api/files/upload', { method: 'POST', + headers: { 'content-length': '1024' }, body: formData, }) @@ -400,6 +454,7 @@ describe('File Upload Security Tests', () => { const req = new Request('http://localhost/api/files/upload', { method: 'POST', + headers: { 'content-length': '1024' }, body: formData, }) @@ -418,6 +473,7 @@ describe('File Upload Security Tests', () => { const req = new Request('http://localhost/api/files/upload', { method: 'POST', + headers: { 'content-length': '1024' }, body: formData, }) @@ -437,6 +493,7 @@ describe('File Upload Security Tests', () => { const req = new Request('http://localhost/api/files/upload', { method: 'POST', + headers: { 'content-length': '1024' }, body: formData, }) @@ -462,6 +519,7 @@ describe('File Upload Security Tests', () => { const req = new Request('http://localhost/api/files/upload', { method: 'POST', + headers: { 'content-length': '1024' }, body: formData, }) @@ -483,6 +541,7 @@ describe('File Upload Security Tests', () => { const req = new Request('http://localhost/api/files/upload', { method: 'POST', + headers: { 'content-length': '1024' }, body: formData, }) diff --git a/apps/sim/app/api/files/upload/route.ts b/apps/sim/app/api/files/upload/route.ts index e1dc599cad..52e6f3a511 100644 --- a/apps/sim/app/api/files/upload/route.ts +++ b/apps/sim/app/api/files/upload/route.ts @@ -10,10 +10,17 @@ import { } from '@/lib/api/contracts/storage-transfer' import { getValidationErrorMessage } from '@/lib/api/server' import { getSession } from '@/lib/auth' +import { + assertKnownSizeWithinLimit, + isPayloadSizeLimitError, + readFileToBufferWithLimit, + readFormDataWithLimit, +} from '@/lib/core/utils/stream-limits' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { captureServerEvent } from '@/lib/posthog/server' import type { StorageContext } from '@/lib/uploads/config' import { generateWorkspaceFileKey } from '@/lib/uploads/contexts/workspace/workspace-file-manager' +import { MAX_WORKSPACE_FORMDATA_FILE_SIZE } from '@/lib/uploads/shared/types' import { isImageFileType, resolveFileType } from '@/lib/uploads/utils/file-utils' import { SUPPORTED_ATTACHMENT_EXTENSIONS, @@ -24,6 +31,7 @@ import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' import { createErrorResponse, InvalidRequestError } from '@/app/api/files/utils' const ALLOWED_EXTENSIONS = new Set(SUPPORTED_ATTACHMENT_EXTENSIONS) +const MAX_MULTIPART_OVERHEAD_BYTES = 1024 * 1024 function validateFileExtension(filename: string): boolean { const extension = filename.split('.').pop()?.toLowerCase() @@ -42,7 +50,10 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - const formData = await request.formData() + const formData = await readFormDataWithLimit(request, { + maxBytes: MAX_WORKSPACE_FORMDATA_FILE_SIZE + MAX_MULTIPART_OVERHEAD_BYTES, + label: 'multipart upload body', + }) const rawFiles = formData.getAll('file') const filesResult = uploadFilesFormFilesSchema.safeParse(rawFiles) @@ -50,6 +61,8 @@ export const POST = withRouteHandler(async (request: NextRequest) => { throw new InvalidRequestError('No files provided') } const files = filesResult.data + const totalFileSize = files.reduce((total, file) => total + file.size, 0) + assertKnownSizeWithinLimit(totalFileSize, MAX_WORKSPACE_FORMDATA_FILE_SIZE, 'uploaded files') const formFieldsResult = uploadFilesFormFieldsSchema.safeParse({ workflowId: formData.get('workflowId'), @@ -90,8 +103,10 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } - const bytes = await file.arrayBuffer() - const buffer = Buffer.from(bytes) + const buffer = await readFileToBufferWithLimit(file, { + maxBytes: MAX_WORKSPACE_FORMDATA_FILE_SIZE, + label: 'uploaded file', + }) // Handle execution context if (context === 'execution') { @@ -423,6 +438,15 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ files: uploadResults }) } catch (error) { logger.error('Error in file upload:', error) + if (isPayloadSizeLimitError(error)) { + return NextResponse.json( + { + error: 'PayloadSizeLimitError', + message: `File exceeds the server upload limit of ${Math.round(error.maxBytes / (1024 * 1024))}MB. Use direct upload for larger workspace files.`, + }, + { status: 413 } + ) + } return createErrorResponse(error instanceof Error ? error : new Error('File upload failed')) } }) diff --git a/apps/sim/app/api/table/[tableId]/import/route.test.ts b/apps/sim/app/api/table/[tableId]/import/route.test.ts index b821961cb6..b51b35ecec 100644 --- a/apps/sim/app/api/table/[tableId]/import/route.test.ts +++ b/apps/sim/app/api/table/[tableId]/import/route.test.ts @@ -112,6 +112,7 @@ function buildTable(overrides: Partial = {}): TableDefinition { async function callPost(form: FormData, { tableId }: { tableId: string } = { tableId: 'tbl_1' }) { const req = new NextRequest(`http://localhost:3000/api/table/${tableId}/import`, { method: 'POST', + headers: { 'content-length': '1024' }, body: form, }) return POST(req, { params: Promise.resolve({ tableId }) }) @@ -182,6 +183,26 @@ describe('POST /api/table/[tableId]/import', () => { expect(data.error).toMatch(/archived/i) }) + it('returns 413 for oversized CSV files before reading their contents', async () => { + const file = createCsvFile('name,age\nAlice,30') + Object.defineProperty(file, 'size', { + value: 26 * 1024 * 1024, + }) + const arrayBufferSpy = vi.spyOn(file, 'arrayBuffer') + + const req = { + formData: async () => createFormData(file), + } as unknown as NextRequest + + const response = await POST(req, { params: Promise.resolve({ tableId: 'tbl_1' }) }) + expect(response.status).toBe(413) + const data = await response.json() + expect(data.error).toMatch(/CSV import file exceeds maximum size/) + expect(arrayBufferSpy).not.toHaveBeenCalled() + expect(mockBatchInsertRowsWithTx).not.toHaveBeenCalled() + expect(mockReplaceTableRowsWithTx).not.toHaveBeenCalled() + }) + it('returns 400 when the CSV is missing a required column', async () => { const response = await callPost(createFormData(createCsvFile('age\n30'))) expect(response.status).toBe(400) @@ -208,6 +229,21 @@ describe('POST /api/table/[tableId]/import', () => { expect(mockReplaceTableRowsWithTx).not.toHaveBeenCalled() }) + it('accepts chunked multipart imports without a content-length header', async () => { + const form = createFormData(createCsvFile('name,age\nAlice,30'), { mode: 'append' }) + const req = new NextRequest('http://localhost:3000/api/table/tbl_1/import', { + method: 'POST', + body: form, + }) + + expect(req.headers.get('content-length')).toBeNull() + + const response = await POST(req, { params: Promise.resolve({ tableId: 'tbl_1' }) }) + + expect(response.status).toBe(200) + expect(mockBatchInsertRowsWithTx).toHaveBeenCalledTimes(1) + }) + it('rejects append when it would exceed maxRows', async () => { mockCheckAccess.mockResolvedValueOnce({ ok: true, diff --git a/apps/sim/app/api/table/[tableId]/import/route.ts b/apps/sim/app/api/table/[tableId]/import/route.ts index c51cde1b2a..9d9ddcfd96 100644 --- a/apps/sim/app/api/table/[tableId]/import/route.ts +++ b/apps/sim/app/api/table/[tableId]/import/route.ts @@ -14,12 +14,18 @@ import { import { getValidationErrorMessage } from '@/lib/api/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' +import { + isPayloadSizeLimitError, + readFileToBufferWithLimit, + readFormDataWithLimit, +} from '@/lib/core/utils/stream-limits' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { addTableColumnsWithTx, batchInsertRowsWithTx, buildAutoMapping, CSV_MAX_BATCH_SIZE, + CSV_MAX_FILE_SIZE_BYTES, type CsvHeaderMapping, CsvImportValidationError, coerceRowsForTable, @@ -34,6 +40,7 @@ import { import { accessError, checkAccess } from '@/app/api/table/utils' const logger = createLogger('TableImportCSVExisting') +const MAX_MULTIPART_OVERHEAD_BYTES = 1024 * 1024 interface RouteParams { params: Promise<{ tableId: string }> @@ -49,7 +56,10 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) } - const formData = await request.formData() + const formData = await readFormDataWithLimit(request, { + maxBytes: CSV_MAX_FILE_SIZE_BYTES + MAX_MULTIPART_OVERHEAD_BYTES, + label: 'CSV import body', + }) const formValidation = csvImportFormSchema.safeParse({ file: formData.get('file'), workspaceId: formData.get('workspaceId'), @@ -59,9 +69,11 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro const rawCreateColumns = formData.get('createColumns') if (!formValidation.success) { + const message = getValidationErrorMessage(formValidation.error) + const isSizeLimit = message.includes('File exceeds maximum allowed size') return NextResponse.json( - { error: getValidationErrorMessage(formValidation.error) }, - { status: 400 } + { error: isSizeLimit ? 'CSV import file exceeds maximum size' : message }, + { status: isSizeLimit ? 413 : 400 } ) } @@ -125,7 +137,10 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro createColumns = createColumnsValidation.data } - const buffer = Buffer.from(await file.arrayBuffer()) + const buffer = await readFileToBufferWithLimit(file, { + maxBytes: CSV_MAX_FILE_SIZE_BYTES, + label: 'CSV import file', + }) const delimiter = extensionValidation.data === 'tsv' ? '\t' : ',' const { headers, rows } = await parseCsvBuffer(buffer, delimiter) @@ -343,14 +358,19 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro const message = toError(error).message logger.error(`[${requestId}] CSV import into existing table failed:`, error) + const isSizeLimitError = + isPayloadSizeLimitError(error) || message.includes('CSV import file exceeds maximum size') const isClientError = message.includes('CSV file has no') || message.includes('already exists') || - message.includes('Invalid column name') + message.includes('Invalid column name') || + isSizeLimitError return NextResponse.json( { error: isClientError ? message : 'Failed to import CSV' }, - { status: isClientError ? 400 : 500 } + { + status: isSizeLimitError ? 413 : isClientError ? 400 : 500, + } ) } }) diff --git a/apps/sim/app/api/table/import-csv/route.test.ts b/apps/sim/app/api/table/import-csv/route.test.ts new file mode 100644 index 0000000000..9844bf6966 --- /dev/null +++ b/apps/sim/app/api/table/import-csv/route.test.ts @@ -0,0 +1,104 @@ +/** + * @vitest-environment node + */ +import { hybridAuthMockFns, permissionsMock, permissionsMockFns } from '@sim/testing' +import type { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockCreateTable, mockParseCsvBuffer, mockGetWorkspaceTableLimits } = vi.hoisted(() => ({ + mockCreateTable: vi.fn(), + mockParseCsvBuffer: vi.fn(), + mockGetWorkspaceTableLimits: vi.fn(), +})) + +vi.mock('@sim/utils/id', () => ({ + generateId: vi.fn().mockReturnValue('deadbeefcafef00d'), + generateShortId: vi.fn().mockReturnValue('short-id'), +})) + +vi.mock('@/lib/table', () => ({ + batchInsertRows: vi.fn(), + CSV_MAX_BATCH_SIZE: 1000, + CSV_MAX_FILE_SIZE_BYTES: 25 * 1024 * 1024, + coerceRowsForTable: vi.fn(), + createTable: mockCreateTable, + deleteTable: vi.fn(), + getWorkspaceTableLimits: mockGetWorkspaceTableLimits, + inferSchemaFromCsv: vi.fn(), + parseCsvBuffer: mockParseCsvBuffer, + sanitizeName: vi.fn((name: string) => name), + TABLE_LIMITS: { + MAX_TABLE_NAME_LENGTH: 64, + }, +})) + +vi.mock('@/app/api/table/utils', () => ({ + normalizeColumn: vi.fn((column) => column), +})) + +vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock) + +import { POST } from '@/app/api/table/import-csv/route' + +function createCsvFile(contents: string, name = 'data.csv', type = 'text/csv'): File { + return new File([contents], name, { type }) +} + +function createFormData(file: File): FormData { + const form = new FormData() + form.append('file', file) + form.append('workspaceId', 'workspace-1') + return form +} + +async function callPost(form: FormData) { + const req = { + formData: async () => form, + } as unknown as NextRequest + return POST(req) +} + +describe('POST /api/table/import-csv', () => { + beforeEach(() => { + vi.clearAllMocks() + hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({ + success: true, + userId: 'user-1', + authType: 'session', + }) + permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('write') + mockGetWorkspaceTableLimits.mockResolvedValue({ + maxRowsPerTable: 1000, + maxTables: 10, + }) + }) + + it('returns 413 for oversized CSV files before reading their contents or creating a table', async () => { + const file = createCsvFile('name,age\nAlice,30') + Object.defineProperty(file, 'size', { + value: 26 * 1024 * 1024, + }) + const arrayBufferSpy = vi.spyOn(file, 'arrayBuffer') + + const response = await callPost(createFormData(file)) + const data = await response.json() + + expect(response.status).toBe(413) + expect(data.error).toMatch(/CSV import file exceeds maximum size/) + expect(arrayBufferSpy).not.toHaveBeenCalled() + expect(mockParseCsvBuffer).not.toHaveBeenCalled() + expect(mockCreateTable).not.toHaveBeenCalled() + }) + + it('accepts chunked multipart requests without a content-length header', async () => { + const req = { + headers: new Headers({ 'transfer-encoding': 'chunked' }), + formData: vi.fn(async () => createFormData(createCsvFile('name\nAlice'))), + } as unknown as NextRequest + + const response = await POST(req) + + expect(response.status).not.toBe(411) + expect(req.formData).toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/table/import-csv/route.ts b/apps/sim/app/api/table/import-csv/route.ts index 11951d0cb2..3192788920 100644 --- a/apps/sim/app/api/table/import-csv/route.ts +++ b/apps/sim/app/api/table/import-csv/route.ts @@ -6,10 +6,16 @@ import { csvExtensionSchema, csvImportFormSchema } from '@/lib/api/contracts/tab import { getValidationErrorMessage } from '@/lib/api/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' +import { + isPayloadSizeLimitError, + readFileToBufferWithLimit, + readFormDataWithLimit, +} from '@/lib/core/utils/stream-limits' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { batchInsertRows, CSV_MAX_BATCH_SIZE, + CSV_MAX_FILE_SIZE_BYTES, coerceRowsForTable, createTable, deleteTable, @@ -24,6 +30,7 @@ import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' import { normalizeColumn } from '@/app/api/table/utils' const logger = createLogger('TableImportCSV') +const MAX_MULTIPART_OVERHEAD_BYTES = 1024 * 1024 export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() @@ -34,16 +41,21 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) } - const formData = await request.formData() + const formData = await readFormDataWithLimit(request, { + maxBytes: CSV_MAX_FILE_SIZE_BYTES + MAX_MULTIPART_OVERHEAD_BYTES, + label: 'CSV import body', + }) const validation = csvImportFormSchema.safeParse({ file: formData.get('file'), workspaceId: formData.get('workspaceId'), }) if (!validation.success) { + const message = getValidationErrorMessage(validation.error) + const isSizeLimit = message.includes('File exceeds maximum allowed size') return NextResponse.json( - { error: getValidationErrorMessage(validation.error) }, - { status: 400 } + { error: isSizeLimit ? 'CSV import file exceeds maximum size' : message }, + { status: isSizeLimit ? 413 : 400 } ) } @@ -63,7 +75,10 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } - const buffer = Buffer.from(await file.arrayBuffer()) + const buffer = await readFileToBufferWithLimit(file, { + maxBytes: CSV_MAX_FILE_SIZE_BYTES, + label: 'CSV import file', + }) const delimiter = extensionValidation.data === 'tsv' ? '\t' : ',' const { headers, rows } = await parseCsvBuffer(buffer, delimiter) @@ -132,16 +147,21 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const message = toError(error).message logger.error(`[${requestId}] CSV import failed:`, error) + const isSizeLimitError = + isPayloadSizeLimitError(error) || message.includes('CSV import file exceeds maximum size') const isClientError = message.includes('maximum table limit') || message.includes('CSV file has no') || message.includes('Invalid table name') || message.includes('Invalid schema') || - message.includes('already exists') + message.includes('already exists') || + isSizeLimitError return NextResponse.json( { error: isClientError ? message : 'Failed to import CSV' }, - { status: isClientError ? 400 : 500 } + { + status: isSizeLimitError ? 413 : isClientError ? 400 : 500, + } ) } }) diff --git a/apps/sim/app/api/tools/docusign/route.ts b/apps/sim/app/api/tools/docusign/route.ts index c88878bb73..594587fc51 100644 --- a/apps/sim/app/api/tools/docusign/route.ts +++ b/apps/sim/app/api/tools/docusign/route.ts @@ -4,30 +4,92 @@ import { type NextRequest, NextResponse } from 'next/server' import { docusignToolContract } from '@/lib/api/contracts/tools/docusign' import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' +import { + assertKnownSizeWithinLimit, + DEFAULT_MAX_ERROR_BODY_BYTES, + isPayloadSizeLimitError, + readResponseJsonWithLimit, + readResponseTextWithLimit, + readResponseToBufferWithLimit, +} from '@/lib/core/utils/stream-limits' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { uploadCopilotFile } from '@/lib/uploads/contexts/copilot' +import { uploadExecutionFile } from '@/lib/uploads/contexts/execution' import { FileInputSchema } from '@/lib/uploads/utils/file-schemas' import { processFilesToUserFiles, type RawFileInput } from '@/lib/uploads/utils/file-utils' import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' import { assertToolFileAccess } from '@/app/api/files/authorization' const logger = createLogger('DocuSignAPI') +const MAX_DOCUSIGN_DOCUMENT_BYTES = 25 * 1024 * 1024 +const MAX_LEGACY_INLINE_DOCUMENT_BYTES = 7 * 1024 * 1024 +const MAX_DOCUSIGN_JSON_BYTES = 2 * 1024 * 1024 +const DOCUSIGN_FETCH_TIMEOUT_MS = 30_000 interface DocuSignAccountInfo { accountId: string baseUri: string } +async function readDocusignJson( + response: Response, + label: string +): Promise> { + return readResponseJsonWithLimit>(response, { + maxBytes: MAX_DOCUSIGN_JSON_BYTES, + label, + }) +} + +function docusignError(data: Record, fallback: string): string { + return ( + (typeof data.message === 'string' && data.message) || + (typeof data.errorCode === 'string' && data.errorCode) || + fallback + ) +} + +async function fetchDocusign( + input: string, + init: RequestInit = {}, + parentSignal?: AbortSignal +): Promise { + const controller = new AbortController() + const timeout = setTimeout(() => { + controller.abort(new Error('DocuSign request timed out')) + }, DOCUSIGN_FETCH_TIMEOUT_MS) + const abort = () => controller.abort(parentSignal?.reason ?? new Error('Request aborted')) + parentSignal?.addEventListener('abort', abort, { once: true }) + + try { + return await fetch(input, { ...init, signal: controller.signal }) + } finally { + clearTimeout(timeout) + parentSignal?.removeEventListener('abort', abort) + } +} + /** * Resolves the user's DocuSign account info from their access token * by calling the DocuSign userinfo endpoint. */ -async function resolveAccount(accessToken: string): Promise { - const response = await fetch('https://account-d.docusign.com/oauth/userinfo', { - headers: { Authorization: `Bearer ${accessToken}` }, - }) +async function resolveAccount( + accessToken: string, + signal?: AbortSignal +): Promise { + const response = await fetchDocusign( + 'https://account-d.docusign.com/oauth/userinfo', + { + headers: { Authorization: `Bearer ${accessToken}` }, + }, + signal + ) if (!response.ok) { - const errorText = await response.text() + const errorText = await readResponseTextWithLimit(response, { + maxBytes: DEFAULT_MAX_ERROR_BODY_BYTES, + label: 'DocuSign account error response', + }).catch(() => '') logger.error('Failed to resolve DocuSign account', { status: response.status, error: errorText, @@ -35,10 +97,16 @@ async function resolveAccount(accessToken: string): Promise throw new Error(`Failed to resolve DocuSign account: ${response.status}`) } - const data = await response.json() - const accounts = data.accounts ?? [] + const data = await readDocusignJson(response, 'DocuSign account response') + const accounts = Array.isArray(data.accounts) + ? (data.accounts as Array<{ + is_default?: boolean + base_uri?: string + account_id?: string + }>) + : [] - const defaultAccount = accounts.find((a: { is_default: boolean }) => a.is_default) ?? accounts[0] + const defaultAccount = accounts.find((account) => account.is_default) ?? accounts[0] if (!defaultAccount) { throw new Error('No DocuSign accounts found for this user') } @@ -47,9 +115,13 @@ async function resolveAccount(accessToken: string): Promise if (!baseUri) { throw new Error('DocuSign account is missing base_uri') } + const accountId = defaultAccount.account_id + if (!accountId) { + throw new Error('DocuSign account is missing account_id') + } return { - accountId: defaultAccount.account_id, + accountId, baseUri, } } @@ -77,7 +149,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const { accessToken, operation, ...params } = parsed.data.body try { - const account = await resolveAccount(accessToken) + const account = await resolveAccount(accessToken, request.signal) const apiBase = `${account.baseUri}/restapi/v2.1/accounts/${account.accountId}` const headers: Record = { Authorization: `Bearer ${accessToken}`, @@ -86,21 +158,27 @@ export const POST = withRouteHandler(async (request: NextRequest) => { switch (operation) { case 'send_envelope': - return await handleSendEnvelope(apiBase, headers, params, authResult.userId) + return await handleSendEnvelope(apiBase, headers, params, authResult.userId, request.signal) case 'create_from_template': - return await handleCreateFromTemplate(apiBase, headers, params) + return await handleCreateFromTemplate(apiBase, headers, params, request.signal) case 'get_envelope': - return await handleGetEnvelope(apiBase, headers, params) + return await handleGetEnvelope(apiBase, headers, params, request.signal) case 'list_envelopes': - return await handleListEnvelopes(apiBase, headers, params) + return await handleListEnvelopes(apiBase, headers, params, request.signal) case 'void_envelope': - return await handleVoidEnvelope(apiBase, headers, params) + return await handleVoidEnvelope(apiBase, headers, params, request.signal) case 'download_document': - return await handleDownloadDocument(apiBase, headers, params) + return await handleDownloadDocument( + apiBase, + headers, + params, + authResult.userId, + request.signal + ) case 'list_templates': - return await handleListTemplates(apiBase, headers, params) + return await handleListTemplates(apiBase, headers, params, request.signal) case 'list_recipients': - return await handleListRecipients(apiBase, headers, params) + return await handleListRecipients(apiBase, headers, params, request.signal) default: return NextResponse.json( { success: false, error: `Unknown operation: ${operation}` }, @@ -110,7 +188,10 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } catch (error) { logger.error('DocuSign API error', { operation, error }) const message = getErrorMessage(error, 'Internal server error') - return NextResponse.json({ success: false, error: message }, { status: 500 }) + return NextResponse.json( + { success: false, error: message }, + { status: isPayloadSizeLimitError(error) ? 413 : 500 } + ) } }) @@ -118,7 +199,8 @@ async function handleSendEnvelope( apiBase: string, headers: Record, params: Record, - userId: string + userId: string, + signal?: AbortSignal ) { const { signerEmail, signerName, emailSubject, emailBody, ccEmail, ccName, file, status } = params @@ -140,15 +222,29 @@ async function handleSendEnvelope( const userFile = userFiles[0] const denied = await assertToolFileAccess(userFile.key, userId, 'docusign-send', logger) if (denied) return denied - const buffer = await downloadFileFromStorage(userFile, 'docusign-send', logger) + if (userFile.size > MAX_DOCUSIGN_DOCUMENT_BYTES) { + return NextResponse.json( + { success: false, error: 'Document is too large to send through DocuSign' }, + { status: 413 } + ) + } + const buffer = await downloadFileFromStorage(userFile, 'docusign-send', logger, { + maxBytes: MAX_DOCUSIGN_DOCUMENT_BYTES, + }) + assertKnownSizeWithinLimit(buffer.length, MAX_DOCUSIGN_DOCUMENT_BYTES, 'DocuSign document') documentBase64 = buffer.toString('base64') documentName = userFile.name } } catch (fileError) { logger.error('Failed to process file for DocuSign envelope', { fileError }) return NextResponse.json( - { success: false, error: 'Failed to process uploaded file' }, - { status: 400 } + { + success: false, + error: isPayloadSizeLimitError(fileError) + ? getErrorMessage(fileError, 'Document is too large to send through DocuSign') + : 'Failed to process uploaded file', + }, + { status: isPayloadSizeLimitError(fileError) ? 413 : 400 } ) } } @@ -216,17 +312,21 @@ async function handleSendEnvelope( ) } - const response = await fetch(`${apiBase}/envelopes`, { - method: 'POST', - headers, - body: JSON.stringify(envelopeBody), - }) + const response = await fetchDocusign( + `${apiBase}/envelopes`, + { + method: 'POST', + headers, + body: JSON.stringify(envelopeBody), + }, + signal + ) - const data = await response.json() + const data = await readDocusignJson(response, 'DocuSign send envelope response') if (!response.ok) { logger.error('DocuSign send envelope failed', { data, status: response.status }) return NextResponse.json( - { success: false, error: data.message || data.errorCode || 'Failed to send envelope' }, + { success: false, error: docusignError(data, 'Failed to send envelope') }, { status: response.status } ) } @@ -237,7 +337,8 @@ async function handleSendEnvelope( async function handleCreateFromTemplate( apiBase: string, headers: Record, - params: Record + params: Record, + signal?: AbortSignal ) { const { templateId, emailSubject, emailBody, templateRoles, status } = params @@ -270,19 +371,23 @@ async function handleCreateFromTemplate( if (emailSubject) envelopeBody.emailSubject = emailSubject if (emailBody) envelopeBody.emailBlurb = emailBody - const response = await fetch(`${apiBase}/envelopes`, { - method: 'POST', - headers, - body: JSON.stringify(envelopeBody), - }) + const response = await fetchDocusign( + `${apiBase}/envelopes`, + { + method: 'POST', + headers, + body: JSON.stringify(envelopeBody), + }, + signal + ) - const data = await response.json() + const data = await readDocusignJson(response, 'DocuSign create from template response') if (!response.ok) { logger.error('DocuSign create from template failed', { data, status: response.status }) return NextResponse.json( { success: false, - error: data.message || data.errorCode || 'Failed to create envelope from template', + error: docusignError(data, 'Failed to create envelope from template'), }, { status: response.status } ) @@ -294,22 +399,24 @@ async function handleCreateFromTemplate( async function handleGetEnvelope( apiBase: string, headers: Record, - params: Record + params: Record, + signal?: AbortSignal ) { const { envelopeId } = params if (!envelopeId) { return NextResponse.json({ success: false, error: 'envelopeId is required' }, { status: 400 }) } - const response = await fetch( + const response = await fetchDocusign( `${apiBase}/envelopes/${(envelopeId as string).trim()}?include=recipients,documents`, - { headers } + { headers }, + signal ) - const data = await response.json() + const data = await readDocusignJson(response, 'DocuSign envelope response') if (!response.ok) { return NextResponse.json( - { success: false, error: data.message || data.errorCode || 'Failed to get envelope' }, + { success: false, error: docusignError(data, 'Failed to get envelope') }, { status: response.status } ) } @@ -320,7 +427,8 @@ async function handleGetEnvelope( async function handleListEnvelopes( apiBase: string, headers: Record, - params: Record + params: Record, + signal?: AbortSignal ) { const queryParams = new URLSearchParams() @@ -338,12 +446,12 @@ async function handleListEnvelopes( if (params.searchText) queryParams.append('search_text', params.searchText as string) if (params.count) queryParams.append('count', params.count as string) - const response = await fetch(`${apiBase}/envelopes?${queryParams}`, { headers }) - const data = await response.json() + const response = await fetchDocusign(`${apiBase}/envelopes?${queryParams}`, { headers }, signal) + const data = await readDocusignJson(response, 'DocuSign envelope list response') if (!response.ok) { return NextResponse.json( - { success: false, error: data.message || data.errorCode || 'Failed to list envelopes' }, + { success: false, error: docusignError(data, 'Failed to list envelopes') }, { status: response.status } ) } @@ -354,7 +462,8 @@ async function handleListEnvelopes( async function handleVoidEnvelope( apiBase: string, headers: Record, - params: Record + params: Record, + signal?: AbortSignal ) { const { envelopeId, voidedReason } = params if (!envelopeId) { @@ -364,16 +473,20 @@ async function handleVoidEnvelope( return NextResponse.json({ success: false, error: 'voidedReason is required' }, { status: 400 }) } - const response = await fetch(`${apiBase}/envelopes/${(envelopeId as string).trim()}`, { - method: 'PUT', - headers, - body: JSON.stringify({ status: 'voided', voidedReason }), - }) + const response = await fetchDocusign( + `${apiBase}/envelopes/${(envelopeId as string).trim()}`, + { + method: 'PUT', + headers, + body: JSON.stringify({ status: 'voided', voidedReason }), + }, + signal + ) - const data = await response.json() + const data = await readDocusignJson(response, 'DocuSign void envelope response') if (!response.ok) { return NextResponse.json( - { success: false, error: data.message || data.errorCode || 'Failed to void envelope' }, + { success: false, error: docusignError(data, 'Failed to void envelope') }, { status: response.status } ) } @@ -384,7 +497,9 @@ async function handleVoidEnvelope( async function handleDownloadDocument( apiBase: string, headers: Record, - params: Record + params: Record, + userId: string, + signal?: AbortSignal ) { const { envelopeId, documentId } = params if (!envelopeId) { @@ -393,17 +508,21 @@ async function handleDownloadDocument( const docId = (documentId as string) || 'combined' - const response = await fetch( + const response = await fetchDocusign( `${apiBase}/envelopes/${(envelopeId as string).trim()}/documents/${docId}`, { headers: { Authorization: headers.Authorization }, - } + }, + signal ) if (!response.ok) { let errorText = '' try { - errorText = await response.text() + errorText = await readResponseTextWithLimit(response, { + maxBytes: DEFAULT_MAX_ERROR_BODY_BYTES, + label: 'DocuSign document error response', + }) } catch { // ignore } @@ -422,16 +541,50 @@ async function handleDownloadDocument( fileName = filenameMatch[1].replace(/['"]/g, '') } - const buffer = Buffer.from(await response.arrayBuffer()) - const base64Content = buffer.toString('base64') + const buffer = await readResponseToBufferWithLimit(response, { + maxBytes: MAX_DOCUSIGN_DOCUMENT_BYTES, + label: 'DocuSign document download', + }) + + const workspaceId = typeof params.workspaceId === 'string' ? params.workspaceId : undefined + const workflowId = typeof params.workflowId === 'string' ? params.workflowId : undefined + const executionId = typeof params.executionId === 'string' ? params.executionId : undefined + const legacyInlineContent = + buffer.length <= MAX_LEGACY_INLINE_DOCUMENT_BYTES + ? { base64Content: buffer.toString('base64') } + : {} + + if (workspaceId && workflowId && executionId) { + const file = await uploadExecutionFile( + { workspaceId, workflowId, executionId }, + buffer, + fileName, + contentType, + userId + ) + return NextResponse.json({ + file, + mimeType: contentType, + fileName, + ...legacyInlineContent, + }) + } + + const file = await uploadCopilotFile({ + buffer, + fileName, + contentType, + userId, + }) - return NextResponse.json({ base64Content, mimeType: contentType, fileName }) + return NextResponse.json({ file, mimeType: contentType, fileName, ...legacyInlineContent }) } async function handleListTemplates( apiBase: string, headers: Record, - params: Record + params: Record, + signal?: AbortSignal ) { const queryParams = new URLSearchParams() if (params.searchText) queryParams.append('search_text', params.searchText as string) @@ -440,12 +593,12 @@ async function handleListTemplates( const queryString = queryParams.toString() const url = queryString ? `${apiBase}/templates?${queryString}` : `${apiBase}/templates` - const response = await fetch(url, { headers }) - const data = await response.json() + const response = await fetchDocusign(url, { headers }, signal) + const data = await readDocusignJson(response, 'DocuSign template list response') if (!response.ok) { return NextResponse.json( - { success: false, error: data.message || data.errorCode || 'Failed to list templates' }, + { success: false, error: docusignError(data, 'Failed to list templates') }, { status: response.status } ) } @@ -456,21 +609,26 @@ async function handleListTemplates( async function handleListRecipients( apiBase: string, headers: Record, - params: Record + params: Record, + signal?: AbortSignal ) { const { envelopeId } = params if (!envelopeId) { return NextResponse.json({ success: false, error: 'envelopeId is required' }, { status: 400 }) } - const response = await fetch(`${apiBase}/envelopes/${(envelopeId as string).trim()}/recipients`, { - headers, - }) - const data = await response.json() + const response = await fetchDocusign( + `${apiBase}/envelopes/${(envelopeId as string).trim()}/recipients`, + { + headers, + }, + signal + ) + const data = await readDocusignJson(response, 'DocuSign recipients response') if (!response.ok) { return NextResponse.json( - { success: false, error: data.message || data.errorCode || 'Failed to list recipients' }, + { success: false, error: docusignError(data, 'Failed to list recipients') }, { status: response.status } ) } diff --git a/apps/sim/app/api/tools/google_slides/export-presentation/route.ts b/apps/sim/app/api/tools/google_slides/export-presentation/route.ts new file mode 100644 index 0000000000..5c36785f8e --- /dev/null +++ b/apps/sim/app/api/tools/google_slides/export-presentation/route.ts @@ -0,0 +1,176 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { googleSlidesExportPresentationContract } from '@/lib/api/contracts/tools/google' +import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { + secureFetchWithPinnedIP, + validateUrlWithDNS, +} from '@/lib/core/security/input-validation.server' +import { + DEFAULT_MAX_ERROR_BODY_BYTES, + isPayloadSizeLimitError, + readResponseTextWithLimit, + readResponseToBufferWithLimit, +} from '@/lib/core/utils/stream-limits' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { uploadCopilotFile } from '@/lib/uploads/contexts/copilot' +import { uploadExecutionFile } from '@/lib/uploads/contexts/execution' +import { presentationUrl } from '@/tools/google_slides/utils' + +const logger = createLogger('GoogleSlidesExportAPI') +const MAX_GOOGLE_SLIDES_EXPORT_BYTES = 10 * 1024 * 1024 +const MAX_LEGACY_INLINE_EXPORT_BYTES = 7 * 1024 * 1024 + +const FORMAT_TO_MIME = { + PDF: 'application/pdf', + PPTX: 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + ODP: 'application/vnd.oasis.opendocument.presentation', + TXT: 'text/plain', + PNG: 'image/png', + JPEG: 'image/jpeg', + SVG: 'image/svg+xml', +} as const + +export const dynamic = 'force-dynamic' + +function buildExportUrl(presentationId: string, exportFormat: keyof typeof FORMAT_TO_MIME): string { + const mimeType = FORMAT_TO_MIME[exportFormat] + return `https://www.googleapis.com/drive/v3/files/${encodeURIComponent(presentationId)}/export?mimeType=${encodeURIComponent(mimeType)}` +} + +function buildExportFilename( + presentationId: string, + exportFormat: keyof typeof FORMAT_TO_MIME +): string { + return `${presentationId}.${exportFormat.toLowerCase()}` +} + +export const POST = withRouteHandler(async (request: NextRequest) => { + const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) + if (!authResult.success || !authResult.userId) { + return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseRequest( + googleSlidesExportPresentationContract, + request, + {}, + { + validationErrorResponse: (error) => + NextResponse.json( + { success: false, error: getValidationErrorMessage(error, 'Invalid request data') }, + { status: 400 } + ), + } + ) + if (!parsed.success) return parsed.response + + try { + const body = parsed.data.body + const exportFormat = body.exportFormat ?? 'PDF' + const mimeType = FORMAT_TO_MIME[exportFormat] + const exportUrl = buildExportUrl(body.presentationId, exportFormat) + const urlValidation = await validateUrlWithDNS(exportUrl, 'googleSlidesExportUrl') + if (!urlValidation.isValid) { + return NextResponse.json( + { success: false, error: urlValidation.error || 'Invalid Google Slides export URL' }, + { status: 400 } + ) + } + + const response = await secureFetchWithPinnedIP(exportUrl, urlValidation.resolvedIP!, { + headers: { Authorization: `Bearer ${body.accessToken}` }, + maxResponseBytes: MAX_GOOGLE_SLIDES_EXPORT_BYTES, + }) + + if (!response.ok) { + const errorText = await readResponseTextWithLimit(response, { + maxBytes: DEFAULT_MAX_ERROR_BODY_BYTES, + label: 'Google Slides export error response', + }).catch(() => '') + return NextResponse.json( + { + success: false, + error: `Failed to export presentation: ${response.status} ${errorText}`, + }, + { status: response.status } + ) + } + + const buffer = await readResponseToBufferWithLimit(response, { + maxBytes: MAX_GOOGLE_SLIDES_EXPORT_BYTES, + label: 'Google Slides export response', + }) + const filename = buildExportFilename(body.presentationId, exportFormat) + const legacyInlineContent = + buffer.length <= MAX_LEGACY_INLINE_EXPORT_BYTES + ? { contentBase64: buffer.toString('base64') } + : {} + const executionContext = + body.workspaceId && body.workflowId && body.executionId + ? { + workspaceId: body.workspaceId, + workflowId: body.workflowId, + executionId: body.executionId, + } + : undefined + + if (executionContext) { + const file = await uploadExecutionFile( + executionContext, + buffer, + filename, + mimeType, + authResult.userId + ) + return NextResponse.json({ + success: true, + output: { + file: { ...file, mimeType }, + exportFormat, + mimeType, + sizeBytes: buffer.length, + exportUrl: file.url, + ...legacyInlineContent, + metadata: { + presentationId: body.presentationId, + url: presentationUrl(body.presentationId), + exportFormat, + }, + }, + }) + } + + const file = await uploadCopilotFile({ + buffer, + fileName: filename, + contentType: mimeType, + userId: authResult.userId, + }) + + return NextResponse.json({ + success: true, + output: { + file, + exportUrl: file.url, + exportFormat, + mimeType, + sizeBytes: buffer.length, + ...legacyInlineContent, + metadata: { + presentationId: body.presentationId, + url: presentationUrl(body.presentationId), + exportFormat, + }, + }, + }) + } catch (error) { + logger.error('Google Slides export failed', { error }) + return NextResponse.json( + { success: false, error: getErrorMessage(error, 'Failed to export presentation') }, + { status: isPayloadSizeLimitError(error) ? 413 : 500 } + ) + } +}) diff --git a/apps/sim/app/api/tools/image/route.ts b/apps/sim/app/api/tools/image/route.ts index d48e5dffd8..b164354240 100644 --- a/apps/sim/app/api/tools/image/route.ts +++ b/apps/sim/app/api/tools/image/route.ts @@ -21,10 +21,21 @@ import { validateUrlWithDNS, } from '@/lib/core/security/input-validation.server' import { generateRequestId } from '@/lib/core/utils/request' +import { + assertKnownSizeWithinLimit, + consumeOrCancelBody, + DEFAULT_MAX_ERROR_BODY_BYTES, + isPayloadSizeLimitError, + readResponseJsonWithLimit, + readResponseTextWithLimit, + readResponseToBufferWithLimit, +} from '@/lib/core/utils/stream-limits' import { getBaseUrl } from '@/lib/core/utils/urls' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' const logger = createLogger('ImageProxyAPI') +const MAX_IMAGE_BYTES = 25 * 1024 * 1024 +const MAX_IMAGE_JSON_BYTES = Math.ceil((MAX_IMAGE_BYTES * 4) / 3) + 256 * 1024 export const dynamic = 'force-dynamic' export const maxDuration = 600 @@ -116,7 +127,10 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } catch (error) { logger.error(`[${requestId}] Image generation failed:`, error) const errorMessage = getErrorMessage(error, 'Image generation failed') - return NextResponse.json({ error: errorMessage }, { status: 500 }) + return NextResponse.json( + { error: errorMessage }, + { status: isPayloadSizeLimitError(error) ? 413 : 500 } + ) } const storedImage = await storeGeneratedImage(imageResult, body, authResult.userId, requestId) @@ -131,7 +145,10 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } catch (error) { logger.error(`[${requestId}] Image generation route error:`, error) const errorMessage = getErrorMessage(error, 'Unknown error') - return NextResponse.json({ error: errorMessage }, { status: 500 }) + return NextResponse.json( + { error: errorMessage }, + { status: isPayloadSizeLimitError(error) ? 413 : 500 } + ) } }) @@ -172,6 +189,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { try { const imageResponse = await secureFetchWithPinnedIP(imageUrl, urlValidation.resolvedIP!, { method: 'GET', + maxResponseBytes: MAX_IMAGE_BYTES, headers: { 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36', @@ -186,6 +204,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { }) if (!imageResponse.ok) { + await consumeOrCancelBody(imageResponse) logger.error(`[${requestId}] Image fetch failed:`, { status: imageResponse.status, statusText: imageResponse.statusText, @@ -197,14 +216,17 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const contentType = imageResponse.headers.get('content-type') || 'image/jpeg' - const imageArrayBuffer = await imageResponse.arrayBuffer() + const imageBuffer = await readResponseToBufferWithLimit(imageResponse, { + maxBytes: MAX_IMAGE_BYTES, + label: 'image proxy response', + }) - if (imageArrayBuffer.byteLength === 0) { + if (imageBuffer.length === 0) { logger.error(`[${requestId}] Empty image received`) return new NextResponse('Empty image received', { status: 404 }) } - return new NextResponse(imageArrayBuffer, { + return new NextResponse(new Uint8Array(imageBuffer), { headers: { 'Content-Type': contentType, 'Access-Control-Allow-Origin': '*', @@ -216,7 +238,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { logger.error(`[${requestId}] Image proxy error:`, { error: errorMessage }) return new NextResponse(`Failed to proxy image: ${errorMessage}`, { - status: 500, + status: isPayloadSizeLimitError(error) ? 413 : 500, }) } }) @@ -458,9 +480,11 @@ async function bufferFromImageUrl(url: string): Promise<{ buffer: Buffer; conten if (url.startsWith('data:')) { const match = /^data:([^;]+);base64,(.+)$/u.exec(url) if (!match) throw new Error('Invalid data URI image response') + const buffer = Buffer.from(match[2], 'base64') + assertKnownSizeWithinLimit(buffer.length, MAX_IMAGE_BYTES, 'inline image response') return { contentType: match[1], - buffer: Buffer.from(match[2], 'base64'), + buffer, } } @@ -471,15 +495,22 @@ async function bufferFromImageUrl(url: string): Promise<{ buffer: Buffer; conten const imageResponse = await secureFetchWithPinnedIP(url, urlValidation.resolvedIP, { method: 'GET', + maxResponseBytes: MAX_IMAGE_BYTES, }) if (!imageResponse.ok) { - await imageResponse.text().catch(() => {}) + await readResponseTextWithLimit(imageResponse, { + maxBytes: DEFAULT_MAX_ERROR_BODY_BYTES, + label: 'generated image error response', + }).catch(() => '') throw new Error(`Failed to download generated image: ${imageResponse.status}`) } const contentType = imageResponse.headers.get('content-type') || 'image/png' - const arrayBuffer = await imageResponse.arrayBuffer() - return { buffer: Buffer.from(arrayBuffer), contentType } + const buffer = await readResponseToBufferWithLimit(imageResponse, { + maxBytes: MAX_IMAGE_BYTES, + label: 'generated image download', + }) + return { buffer, contentType } } async function generateWithOpenAI( @@ -524,11 +555,17 @@ async function generateWithOpenAI( }) if (!openaiResponse.ok) { - const error = await openaiResponse.text() + const error = await readResponseTextWithLimit(openaiResponse, { + maxBytes: DEFAULT_MAX_ERROR_BODY_BYTES, + label: 'OpenAI image error response', + }) throw new Error(`OpenAI API error: ${openaiResponse.status} - ${error}`) } - const data = (await openaiResponse.json()) as unknown + const data = await readResponseJsonWithLimit(openaiResponse, { + maxBytes: MAX_IMAGE_JSON_BYTES, + label: 'OpenAI image response', + }) if (!isRecord(data)) { throw new Error('Invalid OpenAI image response') } @@ -542,6 +579,7 @@ async function generateWithOpenAI( if (base64Image) { buffer = Buffer.from(base64Image, 'base64') + assertKnownSizeWithinLimit(buffer.length, MAX_IMAGE_BYTES, 'OpenAI image response') } else if (imageUrl) { const downloaded = await bufferFromImageUrl(imageUrl) buffer = downloaded.buffer @@ -611,11 +649,17 @@ async function generateWithGemini( ) if (!geminiResponse.ok) { - const error = await geminiResponse.text() + const error = await readResponseTextWithLimit(geminiResponse, { + maxBytes: DEFAULT_MAX_ERROR_BODY_BYTES, + label: 'Gemini image error response', + }) throw new Error(`Gemini API error: ${geminiResponse.status} - ${error}`) } - const data = (await geminiResponse.json()) as unknown + const data = await readResponseJsonWithLimit(geminiResponse, { + maxBytes: MAX_IMAGE_JSON_BYTES, + label: 'Gemini image response', + }) if (!isRecord(data)) { throw new Error('Invalid Gemini image response') } @@ -650,7 +694,11 @@ async function generateWithGemini( } return { - buffer: Buffer.from(base64Image, 'base64'), + buffer: (() => { + const buffer = Buffer.from(base64Image, 'base64') + assertKnownSizeWithinLimit(buffer.length, MAX_IMAGE_BYTES, 'Gemini image response') + return buffer + })(), contentType, fileName: `gemini-${model}.${extensionFromContentType(contentType)}`, provider: 'gemini', @@ -767,11 +815,17 @@ async function generateWithFalAI( }) if (!createResponse.ok) { - const error = await createResponse.text() + const error = await readResponseTextWithLimit(createResponse, { + maxBytes: DEFAULT_MAX_ERROR_BODY_BYTES, + label: 'Fal.ai create error response', + }) throw new Error(`Fal.ai API error: ${createResponse.status} - ${error}`) } - const createData = (await createResponse.json()) as unknown + const createData = await readResponseJsonWithLimit(createResponse, { + maxBytes: MAX_IMAGE_JSON_BYTES, + label: 'Fal.ai create response', + }) if (!isRecord(createData)) { throw new Error('Invalid Fal.ai queue response') } @@ -804,11 +858,17 @@ async function generateWithFalAI( }) if (!statusResponse.ok) { - await statusResponse.text().catch(() => {}) + await readResponseTextWithLimit(statusResponse, { + maxBytes: DEFAULT_MAX_ERROR_BODY_BYTES, + label: 'Fal.ai status error response', + }).catch(() => '') throw new Error(`Fal.ai status check failed: ${statusResponse.status}`) } - const statusData = (await statusResponse.json()) as unknown + const statusData = await readResponseJsonWithLimit(statusResponse, { + maxBytes: MAX_IMAGE_JSON_BYTES, + label: 'Fal.ai status response', + }) if (!isRecord(statusData)) { throw new Error('Invalid Fal.ai status response') } @@ -830,11 +890,17 @@ async function generateWithFalAI( ) if (!resultResponse.ok) { - await resultResponse.text().catch(() => {}) + await readResponseTextWithLimit(resultResponse, { + maxBytes: DEFAULT_MAX_ERROR_BODY_BYTES, + label: 'Fal.ai result error response', + }).catch(() => '') throw new Error(`Failed to fetch Fal.ai result: ${resultResponse.status}`) } - const resultData = (await resultResponse.json()) as unknown + const resultData = await readResponseJsonWithLimit(resultResponse, { + maxBytes: MAX_IMAGE_JSON_BYTES, + label: 'Fal.ai result response', + }) if (!isRecord(resultData)) { throw new Error('Invalid Fal.ai result response') } diff --git a/apps/sim/app/api/tools/tts/route.ts b/apps/sim/app/api/tools/tts/route.ts index 929e995c1d..366d2ee03e 100644 --- a/apps/sim/app/api/tools/tts/route.ts +++ b/apps/sim/app/api/tools/tts/route.ts @@ -7,11 +7,16 @@ import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { DEFAULT_EXECUTION_TIMEOUT_MS } from '@/lib/core/execution-limits' import { validateAlphanumericId } from '@/lib/core/security/input-validation' +import { + isPayloadSizeLimitError, + readResponseToBufferWithLimit, +} from '@/lib/core/utils/stream-limits' import { getBaseUrl } from '@/lib/core/utils/urls' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { StorageService } from '@/lib/uploads' const logger = createLogger('ProxyTTSAPI') +const MAX_TTS_AUDIO_BYTES = 25 * 1024 * 1024 export const POST = withRouteHandler(async (request: NextRequest) => { try { @@ -98,14 +103,17 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } - const audioBlob = await response.blob() + const audioBuffer = await readResponseToBufferWithLimit(response, { + maxBytes: MAX_TTS_AUDIO_BYTES, + label: 'TTS audio response', + signal: request.signal, + }) - if (audioBlob.size === 0) { + if (audioBuffer.length === 0) { logger.error('Empty audio received from ElevenLabs') return NextResponse.json({ error: 'Empty audio received' }, { status: 422 }) } - const audioBuffer = Buffer.from(await audioBlob.arrayBuffer()) const timestamp = Date.now() // Use execution storage for workflow tool calls, copilot for chat UI @@ -160,7 +168,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { { error: `Internal Server Error: ${getErrorMessage(error, 'Unknown error')}`, }, - { status: 500 } + { status: isPayloadSizeLimitError(error) ? 413 : 500 } ) } }) diff --git a/apps/sim/app/api/tools/tts/unified/route.ts b/apps/sim/app/api/tools/tts/unified/route.ts index 3b6f0a7870..80cc10db05 100644 --- a/apps/sim/app/api/tools/tts/unified/route.ts +++ b/apps/sim/app/api/tools/tts/unified/route.ts @@ -10,6 +10,13 @@ import { import { getValidationErrorMessage, parseRequest, validationErrorResponse } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { validateAlphanumericId } from '@/lib/core/security/input-validation' +import { + assertKnownSizeWithinLimit, + isPayloadSizeLimitError, + readResponseJsonWithLimit, + readResponseTextWithLimit, + readResponseToBufferWithLimit, +} from '@/lib/core/utils/stream-limits' import { getBaseUrl } from '@/lib/core/utils/urls' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { StorageService } from '@/lib/uploads' @@ -26,6 +33,36 @@ import type { import { getFileExtension, getMimeType } from '@/tools/tts/types' const logger = createLogger('TtsUnifiedProxyAPI') +const MAX_TTS_AUDIO_BYTES = 25 * 1024 * 1024 +const MAX_TTS_ERROR_BYTES = 64 * 1024 +const MAX_TTS_JSON_BYTES = Math.ceil((MAX_TTS_AUDIO_BYTES * 4) / 3) + 256 * 1024 + +async function readTtsErrorJson( + response: Response, + label: string +): Promise> { + return readResponseJsonWithLimit>(response, { + maxBytes: MAX_TTS_ERROR_BYTES, + label, + }).catch(() => ({})) +} + +function getTtsErrorMessage(error: Record, fallback: string): string { + const nested = error.error + if (typeof nested === 'object' && nested !== null && 'message' in nested) { + const message = (nested as { message?: unknown }).message + if (typeof message === 'string') return message + } + for (const key of ['message', 'err_msg', 'error_message', 'error', 'detail']) { + const value = error[key] + if (typeof value === 'string') return value + if (typeof value === 'object' && value !== null && 'message' in value) { + const message = (value as { message?: unknown }).message + if (typeof message === 'string') return message + } + } + return fallback +} export const dynamic = 'force-dynamic' export const maxDuration = 60 // 1 minute @@ -208,7 +245,10 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } catch (error) { logger.error(`[${requestId}] TTS synthesis failed:`, error) const errorMessage = getErrorMessage(error, 'TTS synthesis failed') - return NextResponse.json({ error: errorMessage }, { status: 500 }) + return NextResponse.json( + { error: errorMessage }, + { status: isPayloadSizeLimitError(error) ? 413 : 500 } + ) } const timestamp = Date.now() @@ -277,7 +317,10 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } catch (error) { logger.error(`[${requestId}] TTS unified proxy error:`, error) const errorMessage = getErrorMessage(error, 'Unknown error') - return NextResponse.json({ error: errorMessage }, { status: 500 }) + return NextResponse.json( + { error: errorMessage }, + { status: isPayloadSizeLimitError(error) ? 413 : 500 } + ) } }) @@ -303,13 +346,15 @@ async function synthesizeWithOpenAi( }) if (!response.ok) { - const error = await response.json().catch(() => ({})) - const errorMessage = error.error?.message || error.message || response.statusText + const error = await readTtsErrorJson(response, 'OpenAI TTS error response') + const errorMessage = getTtsErrorMessage(error, response.statusText) throw new Error(`OpenAI TTS API error: ${errorMessage}`) } - const arrayBuffer = await response.arrayBuffer() - const audioBuffer = Buffer.from(arrayBuffer) + const audioBuffer = await readResponseToBufferWithLimit(response, { + maxBytes: MAX_TTS_AUDIO_BYTES, + label: 'OpenAI TTS audio response', + }) const mimeType = getMimeType(responseFormat) return { @@ -359,13 +404,15 @@ async function synthesizeWithDeepgram( }) if (!response.ok) { - const error = await response.json().catch(() => ({})) - const errorMessage = error.err_msg || error.message || response.statusText + const error = await readTtsErrorJson(response, 'Deepgram TTS error response') + const errorMessage = getTtsErrorMessage(error, response.statusText) throw new Error(`Deepgram TTS API error: ${errorMessage}`) } - const arrayBuffer = await response.arrayBuffer() - const audioBuffer = Buffer.from(arrayBuffer) + const audioBuffer = await readResponseToBufferWithLimit(response, { + maxBytes: MAX_TTS_AUDIO_BYTES, + label: 'Deepgram TTS audio response', + }) let finalFormat: string = encoding if (container === 'wav') { @@ -422,16 +469,15 @@ async function synthesizeWithElevenLabs( }) if (!response.ok) { - const error = await response.json().catch(() => ({})) - const errorMessage = - typeof error.detail === 'string' - ? error.detail - : error.detail?.message || error.message || response.statusText + const error = await readTtsErrorJson(response, 'ElevenLabs TTS error response') + const errorMessage = getTtsErrorMessage(error, response.statusText) throw new Error(`ElevenLabs TTS API error: ${errorMessage}`) } - const arrayBuffer = await response.arrayBuffer() - const audioBuffer = Buffer.from(arrayBuffer) + const audioBuffer = await readResponseToBufferWithLimit(response, { + maxBytes: MAX_TTS_AUDIO_BYTES, + label: 'ElevenLabs TTS audio response', + }) return { audioBuffer, @@ -509,9 +555,9 @@ async function synthesizeWithCartesia( }) if (!response.ok) { - const error = await response.json().catch(() => ({})) - const errorMessage = error.error || error.message || response.statusText - const errorDetail = error.detail || '' + const error = await readTtsErrorJson(response, 'Cartesia TTS error response') + const errorMessage = getTtsErrorMessage(error, response.statusText) + const errorDetail = typeof error.detail === 'string' ? error.detail : '' logger.error('Cartesia API error details:', { status: response.status, error: errorMessage, @@ -523,8 +569,10 @@ async function synthesizeWithCartesia( ) } - const arrayBuffer = await response.arrayBuffer() - const audioBuffer = Buffer.from(arrayBuffer) + const audioBuffer = await readResponseToBufferWithLimit(response, { + maxBytes: MAX_TTS_AUDIO_BYTES, + label: 'Cartesia TTS audio response', + }) const format = outputFormat && typeof outputFormat === 'object' && 'container' in outputFormat @@ -616,12 +664,15 @@ async function synthesizeWithGoogle( ) if (!response.ok) { - const error = await response.json().catch(() => ({})) - const errorMessage = error.error?.message || error.message || response.statusText + const error = await readTtsErrorJson(response, 'Google TTS error response') + const errorMessage = getTtsErrorMessage(error, response.statusText) throw new Error(`Google Cloud TTS API error: ${errorMessage}`) } - const data = await response.json() + const data = await readResponseJsonWithLimit<{ audioContent?: string }>(response, { + maxBytes: MAX_TTS_JSON_BYTES, + label: 'Google TTS JSON response', + }) const audioContent = data.audioContent if (!audioContent) { @@ -629,6 +680,7 @@ async function synthesizeWithGoogle( } const audioBuffer = Buffer.from(audioContent, 'base64') + assertKnownSizeWithinLimit(audioBuffer.length, MAX_TTS_AUDIO_BYTES, 'Google TTS audio response') const format = audioEncoding.toLowerCase().replace('_', '') const mimeType = getMimeType(format) @@ -706,12 +758,17 @@ async function synthesizeWithAzure( }) if (!response.ok) { - const error = await response.text() + const error = await readResponseTextWithLimit(response, { + maxBytes: MAX_TTS_ERROR_BYTES, + label: 'Azure TTS error response', + }) throw new Error(`Azure TTS API error: ${error || response.statusText}`) } - const arrayBuffer = await response.arrayBuffer() - const audioBuffer = Buffer.from(arrayBuffer) + const audioBuffer = await readResponseToBufferWithLimit(response, { + maxBytes: MAX_TTS_AUDIO_BYTES, + label: 'Azure TTS audio response', + }) const format = outputFormat.includes('mp3') ? 'mp3' : 'wav' const mimeType = getMimeType(format) @@ -768,13 +825,15 @@ async function synthesizeWithPlayHT( }) if (!response.ok) { - const error = await response.json().catch(() => ({})) - const errorMessage = error.error_message || error.message || response.statusText + const error = await readTtsErrorJson(response, 'PlayHT TTS error response') + const errorMessage = getTtsErrorMessage(error, response.statusText) throw new Error(`PlayHT TTS API error: ${errorMessage}`) } - const arrayBuffer = await response.arrayBuffer() - const audioBuffer = Buffer.from(arrayBuffer) + const audioBuffer = await readResponseToBufferWithLimit(response, { + maxBytes: MAX_TTS_AUDIO_BYTES, + label: 'PlayHT TTS audio response', + }) const format = outputFormat || 'mp3' const mimeType = getMimeType(format) diff --git a/apps/sim/app/api/tools/typeform/files/route.ts b/apps/sim/app/api/tools/typeform/files/route.ts new file mode 100644 index 0000000000..f4ded92ff9 --- /dev/null +++ b/apps/sim/app/api/tools/typeform/files/route.ts @@ -0,0 +1,168 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { typeformFilesContract } from '@/lib/api/contracts/tools/typeform' +import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { + secureFetchWithPinnedIP, + validateUrlWithDNS, +} from '@/lib/core/security/input-validation.server' +import { + DEFAULT_MAX_ERROR_BODY_BYTES, + isPayloadSizeLimitError, + readResponseTextWithLimit, + readResponseToBufferWithLimit, +} from '@/lib/core/utils/stream-limits' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { uploadCopilotFile } from '@/lib/uploads/contexts/copilot' +import { uploadExecutionFile } from '@/lib/uploads/contexts/execution' + +const logger = createLogger('TypeformFilesAPI') +const MAX_TYPEFORM_FILE_BYTES = 10 * 1024 * 1024 + +export const dynamic = 'force-dynamic' + +function buildTypeformFileUrl({ + formId, + responseId, + fieldId, + filename, + inline, +}: { + formId: string + responseId: string + fieldId: string + filename: string + inline?: boolean +}): string { + const encodedFormId = encodeURIComponent(formId) + const encodedResponseId = encodeURIComponent(responseId) + const encodedFieldId = encodeURIComponent(fieldId) + const encodedFilename = encodeURIComponent(filename) + const url = new URL( + `https://api.typeform.com/forms/${encodedFormId}/responses/${encodedResponseId}/fields/${encodedFieldId}/files/${encodedFilename}` + ) + if (inline !== undefined) { + url.searchParams.set('inline', String(inline)) + } + return url.toString() +} + +function getFilename( + response: { headers: { get(name: string): string | null } }, + fallback: string +): string { + const contentDisposition = response.headers.get('content-disposition') || '' + const filenameMatch = contentDisposition.match(/filename="(.+?)"/) + return filenameMatch?.[1] || fallback || 'typeform-file' +} + +export const POST = withRouteHandler(async (request: NextRequest) => { + const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) + if (!authResult.success || !authResult.userId) { + return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseRequest( + typeformFilesContract, + request, + {}, + { + validationErrorResponse: (error) => + NextResponse.json( + { success: false, error: getValidationErrorMessage(error, 'Invalid request data') }, + { status: 400 } + ), + } + ) + if (!parsed.success) return parsed.response + + try { + const body = parsed.data.body + const fileUrl = buildTypeformFileUrl(body) + const urlValidation = await validateUrlWithDNS(fileUrl, 'typeformFileUrl') + if (!urlValidation.isValid) { + return NextResponse.json( + { success: false, error: urlValidation.error || 'Invalid Typeform file URL' }, + { status: 400 } + ) + } + + const response = await secureFetchWithPinnedIP(fileUrl, urlValidation.resolvedIP!, { + headers: { Authorization: `Bearer ${body.apiKey}` }, + maxResponseBytes: MAX_TYPEFORM_FILE_BYTES, + }) + + if (!response.ok) { + const errorText = await readResponseTextWithLimit(response, { + maxBytes: DEFAULT_MAX_ERROR_BODY_BYTES, + label: 'Typeform file error response', + }).catch(() => '') + return NextResponse.json( + { + success: false, + error: `Failed to download Typeform file: ${response.status} ${errorText}`, + }, + { status: response.status } + ) + } + + const buffer = await readResponseToBufferWithLimit(response, { + maxBytes: MAX_TYPEFORM_FILE_BYTES, + label: 'Typeform file download', + }) + const contentType = response.headers.get('content-type') || 'application/octet-stream' + const filename = getFilename(response, body.filename) + const executionContext = + body.workspaceId && body.workflowId && body.executionId + ? { + workspaceId: body.workspaceId, + workflowId: body.workflowId, + executionId: body.executionId, + } + : undefined + + if (executionContext) { + const file = await uploadExecutionFile( + executionContext, + buffer, + filename, + contentType, + authResult.userId + ) + return NextResponse.json({ + success: true, + output: { + fileUrl: file.url, + file: { ...file, mimeType: contentType }, + contentType, + filename, + }, + }) + } + + const file = await uploadCopilotFile({ + buffer, + fileName: filename, + contentType, + userId: authResult.userId, + }) + + return NextResponse.json({ + success: true, + output: { + fileUrl: file.url || fileUrl, + file, + contentType, + filename, + }, + }) + } catch (error) { + logger.error('Typeform file download failed', { error }) + return NextResponse.json( + { success: false, error: getErrorMessage(error, 'Failed to download Typeform file') }, + { status: isPayloadSizeLimitError(error) ? 413 : 500 } + ) + } +}) diff --git a/apps/sim/app/api/tools/video/route.ts b/apps/sim/app/api/tools/video/route.ts index 693a6e192c..0a6f3ddead 100644 --- a/apps/sim/app/api/tools/video/route.ts +++ b/apps/sim/app/api/tools/video/route.ts @@ -7,16 +7,52 @@ import { videoProviders, videoToolContract } from '@/lib/api/contracts/tools/med import { getValidationErrorMessage, parseRequest, validationErrorResponse } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { getMaxExecutionTimeout } from '@/lib/core/execution-limits' +import { + assertKnownSizeWithinLimit, + DEFAULT_MAX_ERROR_BODY_BYTES, + isPayloadSizeLimitError, + PayloadSizeLimitError, + readResponseJsonWithLimit, + readResponseTextWithLimit, + readResponseToBufferWithLimit, +} from '@/lib/core/utils/stream-limits' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' import { assertToolFileAccess } from '@/app/api/files/authorization' import type { UserFile } from '@/executor/types' const logger = createLogger('VideoProxyAPI') +const MAX_VIDEO_OUTPUT_BYTES = 250 * 1024 * 1024 +const MAX_VIDEO_REFERENCE_IMAGE_BYTES = 25 * 1024 * 1024 +const MAX_VIDEO_JSON_BYTES = 2 * 1024 * 1024 export const dynamic = 'force-dynamic' export const maxDuration = 600 // 10 minutes for video generation +async function readVideoResponseBuffer(response: Response, label: string): Promise { + return readResponseToBufferWithLimit(response, { + maxBytes: MAX_VIDEO_OUTPUT_BYTES, + label, + }) +} + +async function readVideoJson>( + response: Response, + label: string +): Promise { + return readResponseJsonWithLimit(response, { + maxBytes: MAX_VIDEO_JSON_BYTES, + label, + }) +} + +async function readVideoErrorText(response: Response, label: string): Promise { + return readResponseTextWithLimit(response, { + maxBytes: DEFAULT_MAX_ERROR_BODY_BYTES, + label, + }).catch(() => '') +} + export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateId() logger.info(`[${requestId}] Video generation request started`) @@ -214,7 +250,10 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } catch (error) { logger.error(`[${requestId}] Video generation failed:`, error) const errorMessage = getErrorMessage(error, 'Video generation failed') - return NextResponse.json({ error: errorMessage }, { status: 500 }) + return NextResponse.json( + { error: errorMessage }, + { status: isPayloadSizeLimitError(error) ? 413 : 500 } + ) } const executionContext = @@ -298,7 +337,10 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } catch (error) { logger.error(`[${requestId}] Video proxy error:`, error) const errorMessage = getErrorMessage(error, 'Unknown error') - return NextResponse.json({ error: errorMessage }, { status: 500 }) + return NextResponse.json( + { error: errorMessage }, + { status: isPayloadSizeLimitError(error) ? 413 : 500 } + ) } }) @@ -333,7 +375,21 @@ async function generateWithRunway( } if (visualReference) { - const refBuffer = await downloadFileFromStorage(visualReference, requestId, logger) + if (visualReference.size > MAX_VIDEO_REFERENCE_IMAGE_BYTES) { + throw new PayloadSizeLimitError({ + label: 'video visual reference', + maxBytes: MAX_VIDEO_REFERENCE_IMAGE_BYTES, + observedBytes: visualReference.size, + }) + } + const refBuffer = await downloadFileFromStorage(visualReference, requestId, logger, { + maxBytes: MAX_VIDEO_REFERENCE_IMAGE_BYTES, + }) + assertKnownSizeWithinLimit( + refBuffer.length, + MAX_VIDEO_REFERENCE_IMAGE_BYTES, + 'video visual reference' + ) const refBase64 = refBuffer.toString('base64') createPayload.promptImage = `data:${visualReference.type};base64,${refBase64}` // Use promptImage } @@ -349,11 +405,11 @@ async function generateWithRunway( }) if (!createResponse.ok) { - const error = await createResponse.text() + const error = await readVideoErrorText(createResponse, 'Runway create error response') throw new Error(`Runway API error: ${createResponse.status} - ${error}`) } - const createData = await createResponse.json() + const createData = await readVideoJson<{ id: string }>(createResponse, 'Runway create response') const taskId = createData.id logger.info(`[${requestId}] Runway task created: ${taskId}`) @@ -373,24 +429,32 @@ async function generateWithRunway( }) if (!statusResponse.ok) { - await statusResponse.text().catch(() => {}) + await readVideoErrorText(statusResponse, 'Runway status error response') throw new Error(`Runway status check failed: ${statusResponse.status}`) } - const statusData = await statusResponse.json() + const statusData = await readVideoJson<{ + status?: string + output?: string[] + failure?: string + }>(statusResponse, 'Runway status response') if (statusData.status === 'SUCCEEDED') { logger.info(`[${requestId}] Runway generation completed after ${attempts * 5}s`) - const videoResponse = await fetch(statusData.output[0]) + const videoUrl = statusData.output?.[0] + if (!videoUrl) { + throw new Error('No video URL in response') + } + + const videoResponse = await fetch(videoUrl) if (!videoResponse.ok) { - await videoResponse.text().catch(() => {}) + await readVideoErrorText(videoResponse, 'Runway video error response') throw new Error(`Failed to download video: ${videoResponse.status}`) } - const arrayBuffer = await videoResponse.arrayBuffer() return { - buffer: Buffer.from(arrayBuffer), + buffer: await readVideoResponseBuffer(videoResponse, 'Runway video response'), width: dimensions.width, height: dimensions.height, jobId: taskId, @@ -455,11 +519,11 @@ async function generateWithVeo( ) if (!createResponse.ok) { - const error = await createResponse.text() + const error = await readVideoErrorText(createResponse, 'Veo create error response') throw new Error(`Veo API error: ${createResponse.status} - ${error}`) } - const createData = await createResponse.json() + const createData = await readVideoJson<{ name: string }>(createResponse, 'Veo create response') const operationName = createData.name logger.info(`[${requestId}] Veo operation created: ${operationName}`) @@ -481,11 +545,17 @@ async function generateWithVeo( ) if (!statusResponse.ok) { - await statusResponse.text().catch(() => {}) + await readVideoErrorText(statusResponse, 'Veo status error response') throw new Error(`Veo status check failed: ${statusResponse.status}`) } - const statusData = await statusResponse.json() + const statusData = await readVideoJson<{ + done?: boolean + error?: { message?: string } + response?: { + generateVideoResponse?: { generatedSamples?: Array<{ video?: { uri?: string } }> } + } + }>(statusResponse, 'Veo status response') if (statusData.done) { if (statusData.error) { @@ -506,13 +576,12 @@ async function generateWithVeo( }) if (!videoResponse.ok) { - await videoResponse.text().catch(() => {}) + await readVideoErrorText(videoResponse, 'Veo video error response') throw new Error(`Failed to download video: ${videoResponse.status}`) } - const arrayBuffer = await videoResponse.arrayBuffer() return { - buffer: Buffer.from(arrayBuffer), + buffer: await readVideoResponseBuffer(videoResponse, 'Veo video response'), width: dimensions.width, height: dimensions.height, jobId: operationName, @@ -570,11 +639,11 @@ async function generateWithLuma( }) if (!createResponse.ok) { - const error = await createResponse.text() + const error = await readVideoErrorText(createResponse, 'Luma create error response') throw new Error(`Luma API error: ${createResponse.status} - ${error}`) } - const createData = await createResponse.json() + const createData = await readVideoJson<{ id: string }>(createResponse, 'Luma create response') const generationId = createData.id logger.info(`[${requestId}] Luma generation created: ${generationId}`) @@ -596,11 +665,15 @@ async function generateWithLuma( ) if (!statusResponse.ok) { - await statusResponse.text().catch(() => {}) + await readVideoErrorText(statusResponse, 'Luma status error response') throw new Error(`Luma status check failed: ${statusResponse.status}`) } - const statusData = await statusResponse.json() + const statusData = await readVideoJson<{ + state?: string + failure_reason?: string + assets?: { video?: string } + }>(statusResponse, 'Luma status response') if (statusData.state === 'completed') { logger.info(`[${requestId}] Luma generation completed after ${attempts * 5}s`) @@ -612,13 +685,12 @@ async function generateWithLuma( const videoResponse = await fetch(videoUrl) if (!videoResponse.ok) { - await videoResponse.text().catch(() => {}) + await readVideoErrorText(videoResponse, 'Luma video error response') throw new Error(`Failed to download video: ${videoResponse.status}`) } - const arrayBuffer = await videoResponse.arrayBuffer() return { - buffer: Buffer.from(arrayBuffer), + buffer: await readVideoResponseBuffer(videoResponse, 'Luma video response'), width: dimensions.width, height: dimensions.height, jobId: generationId, @@ -677,7 +749,7 @@ async function generateWithMiniMax( }) if (!createResponse.ok) { - const errorText = await createResponse.text() + const errorText = await readVideoErrorText(createResponse, 'MiniMax create error response') if (createResponse.status === 401 || createResponse.status === 1004) { throw new Error( `MiniMax API authentication failed (${createResponse.status}). Please ensure you're using a valid MiniMax API key from platform.minimax.io. Error: ${errorText}` @@ -686,7 +758,10 @@ async function generateWithMiniMax( throw new Error(`MiniMax API error: ${createResponse.status} - ${errorText}`) } - const createData = await createResponse.json() + const createData = await readVideoJson<{ + base_resp?: { status_code?: number; status_msg?: string } + task_id?: string + }>(createResponse, 'MiniMax create response') // Check for error in response if (createData.base_resp?.status_code !== 0) { @@ -694,6 +769,9 @@ async function generateWithMiniMax( } const taskId = createData.task_id + if (!taskId) { + throw new Error('MiniMax response missing task_id') + } logger.info(`[${requestId}] MiniMax task created: ${taskId}`) @@ -714,11 +792,16 @@ async function generateWithMiniMax( ) if (!statusResponse.ok) { - await statusResponse.text().catch(() => {}) + await readVideoErrorText(statusResponse, 'MiniMax status error response') throw new Error(`MiniMax status check failed: ${statusResponse.status}`) } - const statusData = await statusResponse.json() + const statusData = await readVideoJson<{ + base_resp?: { status_code?: number; status_msg?: string } + status?: string + file_id?: string + error?: string + }>(statusResponse, 'MiniMax status response') if ( statusData.base_resp?.status_code !== 0 && @@ -748,11 +831,14 @@ async function generateWithMiniMax( ) if (!fileResponse.ok) { - await fileResponse.text().catch(() => {}) + await readVideoErrorText(fileResponse, 'MiniMax file error response') throw new Error(`Failed to download video: ${fileResponse.status}`) } - const fileData = await fileResponse.json() + const fileData = await readVideoJson<{ file?: { download_url?: string } }>( + fileResponse, + 'MiniMax file response' + ) const videoUrl = fileData.file?.download_url if (!videoUrl) { @@ -762,13 +848,12 @@ async function generateWithMiniMax( // Download the actual video file const videoResponse = await fetch(videoUrl) if (!videoResponse.ok) { - await videoResponse.text().catch(() => {}) + await readVideoErrorText(videoResponse, 'MiniMax video error response') throw new Error(`Failed to download video from URL: ${videoResponse.status}`) } - const arrayBuffer = await videoResponse.arrayBuffer() return { - buffer: Buffer.from(arrayBuffer), + buffer: await readVideoResponseBuffer(videoResponse, 'MiniMax video response'), width: dimensions.width, height: dimensions.height, jobId: taskId, @@ -1125,11 +1210,11 @@ async function generateWithFalAI( }) if (!createResponse.ok) { - const error = await createResponse.text() + const error = await readVideoErrorText(createResponse, 'Fal.ai create error response') throw new Error(`Fal.ai API error: ${createResponse.status} - ${error}`) } - const createData = (await createResponse.json()) as unknown + const createData = await readVideoJson(createResponse, 'Fal.ai queue response') if (!isRecord(createData)) { throw new Error('Invalid Fal.ai queue response') } @@ -1162,11 +1247,11 @@ async function generateWithFalAI( }) if (!statusResponse.ok) { - await statusResponse.text().catch(() => {}) + await readVideoErrorText(statusResponse, 'Fal.ai status error response') throw new Error(`Fal.ai status check failed: ${statusResponse.status}`) } - const statusData = (await statusResponse.json()) as unknown + const statusData = await readVideoJson(statusResponse, 'Fal.ai status response') if (!isRecord(statusData)) { throw new Error('Invalid Fal.ai status response') } @@ -1189,11 +1274,11 @@ async function generateWithFalAI( ) if (!resultResponse.ok) { - await resultResponse.text().catch(() => {}) + await readVideoErrorText(resultResponse, 'Fal.ai result error response') throw new Error(`Failed to fetch result: ${resultResponse.status}`) } - const resultData = (await resultResponse.json()) as unknown + const resultData = await readVideoJson(resultResponse, 'Fal.ai result response') if (!isRecord(resultData)) { throw new Error('Invalid Fal.ai result response') } @@ -1208,12 +1293,10 @@ async function generateWithFalAI( const videoResponse = await fetch(videoUrl) if (!videoResponse.ok) { - await videoResponse.text().catch(() => {}) + await readVideoErrorText(videoResponse, 'Fal.ai video error response') throw new Error(`Failed to download video: ${videoResponse.status}`) } - const arrayBuffer = await videoResponse.arrayBuffer() - let width = getNumberProperty(videoOutput, 'width') || 1920 let height = getNumberProperty(videoOutput, 'height') || 1080 @@ -1224,7 +1307,7 @@ async function generateWithFalAI( } return { - buffer: Buffer.from(arrayBuffer), + buffer: await readVideoResponseBuffer(videoResponse, 'Fal.ai video response'), width, height, jobId: requestIdFal, diff --git a/apps/sim/app/api/v1/files/route.ts b/apps/sim/app/api/v1/files/route.ts index a286a655de..06d965ac02 100644 --- a/apps/sim/app/api/v1/files/route.ts +++ b/apps/sim/app/api/v1/files/route.ts @@ -5,6 +5,11 @@ import { type NextRequest, NextResponse } from 'next/server' import { v1ListFilesContract, v1UploadFileFormFieldsSchema } from '@/lib/api/contracts/v1/files' import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' import { generateRequestId } from '@/lib/core/utils/request' +import { + isPayloadSizeLimitError, + readFileToBufferWithLimit, + readFormDataWithLimit, +} from '@/lib/core/utils/stream-limits' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { FileConflictError, @@ -25,6 +30,7 @@ export const dynamic = 'force-dynamic' export const revalidate = 0 const MAX_FILE_SIZE = 100 * 1024 * 1024 // 100MB +const MAX_MULTIPART_OVERHEAD_BYTES = 1024 * 1024 /** GET /api/v1/files — List all files in a workspace. */ export const GET = withRouteHandler(async (request: NextRequest) => { @@ -83,8 +89,14 @@ export const POST = withRouteHandler(async (request: NextRequest) => { let formData: FormData try { - formData = await request.formData() - } catch { + formData = await readFormDataWithLimit(request, { + maxBytes: MAX_FILE_SIZE + MAX_MULTIPART_OVERHEAD_BYTES, + label: 'workspace file upload body', + }) + } catch (error) { + if (isPayloadSizeLimitError(error)) { + return NextResponse.json({ error: error.message }, { status: 413 }) + } return NextResponse.json( { error: 'Request body must be valid multipart form data' }, { status: 400 } @@ -117,14 +129,17 @@ export const POST = withRouteHandler(async (request: NextRequest) => { { error: `File size exceeds 100MB limit (${(file.size / (1024 * 1024)).toFixed(2)}MB)`, }, - { status: 400 } + { status: 413 } ) } const accessError = await validateWorkspaceAccess(rateLimit, userId, workspaceId, 'write') if (accessError) return accessError - const buffer = Buffer.from(await file.arrayBuffer()) + const buffer = await readFileToBufferWithLimit(file, { + maxBytes: MAX_FILE_SIZE, + label: 'workspace upload file', + }) const userFile = await uploadWorkspaceFile( workspaceId, @@ -172,6 +187,10 @@ export const POST = withRouteHandler(async (request: NextRequest) => { }, }) } catch (error) { + if (isPayloadSizeLimitError(error)) { + return NextResponse.json({ error: error.message }, { status: 413 }) + } + const errorMessage = getErrorMessage(error, 'Failed to upload file') const isDuplicate = error instanceof FileConflictError || errorMessage.includes('already exists') diff --git a/apps/sim/background/cleanup-logs.ts b/apps/sim/background/cleanup-logs.ts index 090f2e4a72..0446860760 100644 --- a/apps/sim/background/cleanup-logs.ts +++ b/apps/sim/background/cleanup-logs.ts @@ -42,6 +42,14 @@ async function filterLargeValueKeysWithoutRetainedReferences( if (keys.length === 0 || deletedLogIds.length === 0) return [] const uniqueKeys = Array.from(new Set(keys)) + const workspaceIds = Array.from( + new Set( + uniqueKeys + .map((key) => key.split('/')[1]) + .filter((workspaceId): workspaceId is string => Boolean(workspaceId)) + ) + ) + if (workspaceIds.length === 0) return [] const referencedKeys = new Set() for (const keyChunk of chunkArray(uniqueKeys, REFERENCE_CHECK_KEY_CHUNK_SIZE)) { @@ -49,7 +57,8 @@ async function filterLargeValueKeysWithoutRetainedReferences( SELECT DISTINCT k.key AS key FROM ${workflowExecutionLogs} AS wel, unnest(${keyChunk}::text[]) AS k(key) - WHERE wel.id <> ALL(${deletedLogIds}::text[]) + WHERE wel.workspace_id = ANY(${workspaceIds}::text[]) + AND wel.id <> ALL(${deletedLogIds}::text[]) AND position(k.key in wel.execution_data::text) > 0 `) for (const row of rows) referencedKeys.add(row.key) @@ -120,6 +129,7 @@ async function cleanupWorkflowExecutionLogs( db .select({ id: workflowExecutionLogs.id, + workspaceId: workflowExecutionLogs.workspaceId, executionId: workflowExecutionLogs.executionId, executionData: workflowExecutionLogs.executionData, files: workflowExecutionLogs.files, diff --git a/apps/sim/blocks/blocks/docusign.ts b/apps/sim/blocks/blocks/docusign.ts index d66c001f37..40ee34ddd5 100644 --- a/apps/sim/blocks/blocks/docusign.ts +++ b/apps/sim/blocks/blocks/docusign.ts @@ -365,6 +365,7 @@ export const DocuSignBlock: BlockConfig = { type: 'json', description: 'Array of CC recipients (recipientId, name, email, status)', }, + file: { type: 'file', description: 'Stored downloaded document file' }, base64Content: { type: 'string', description: 'Base64-encoded document content' }, mimeType: { type: 'string', description: 'Document MIME type' }, fileName: { type: 'string', description: 'Document file name' }, diff --git a/apps/sim/blocks/blocks/file.test.ts b/apps/sim/blocks/blocks/file.test.ts new file mode 100644 index 0000000000..4778c1faa2 --- /dev/null +++ b/apps/sim/blocks/blocks/file.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest' +import { FileV4Block } from '@/blocks/blocks/file' + +describe('FileV4Block', () => { + const buildParams = FileV4Block.tools.config.params + + it('accepts http and https URLs for fetch', () => { + expect( + buildParams({ + operation: 'file_fetch', + fileUrl: 'https://example.com/image.jpg', + _context: { + workspaceId: 'workspace-1', + workflowId: 'workflow-1', + executionId: 'execution-1', + }, + }) + ).toMatchObject({ + filePath: 'https://example.com/image.jpg', + workspaceId: 'workspace-1', + workflowId: 'workflow-1', + executionId: 'execution-1', + }) + }) + + it('rejects inline content for fetch', () => { + expect(() => + buildParams({ + operation: 'file_fetch', + fileUrl: '\u0001\u0002raw jpeg bytes', + }) + ).toThrow('File URL must be a valid http or https URL') + }) + + it('rejects data URLs for fetch', () => { + expect(() => + buildParams({ + operation: 'file_fetch', + fileUrl: 'data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD', + }) + ).toThrow('File URL must use http or https') + }) + + it('rejects valid URLs with unsupported protocols for fetch', () => { + expect(() => + buildParams({ + operation: 'file_fetch', + fileUrl: 'ftp://example.com/file.pdf', + }) + ).toThrow('File URL must use http or https') + }) +}) diff --git a/apps/sim/blocks/blocks/file.ts b/apps/sim/blocks/blocks/file.ts index 43904e9816..d7ff9dd5d8 100644 --- a/apps/sim/blocks/blocks/file.ts +++ b/apps/sim/blocks/blocks/file.ts @@ -44,6 +44,26 @@ const resolveFilePathsFromInput = (fileInput: unknown): string[] => { return resolved ? [resolved] : [] } +const resolveHttpFileUrl = (value: unknown): string => { + const fileUrl = typeof value === 'string' ? value.trim() : '' + if (!fileUrl) { + throw new Error('File URL is required') + } + + let parsed: URL + try { + parsed = new URL(fileUrl) + } catch { + throw new Error('File URL must be a valid http or https URL') + } + + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + throw new Error('File URL must use http or https') + } + + return fileUrl +} + export const FileBlock: BlockConfig = { type: 'file', name: 'File (Legacy)', @@ -733,11 +753,7 @@ export const FileV4Block: BlockConfig = { } if (operation === 'file_fetch') { - const fileUrl = typeof params.fileUrl === 'string' ? params.fileUrl.trim() : '' - if (!fileUrl) { - logger.error('No file URL provided') - throw new Error('File URL is required') - } + const fileUrl = resolveHttpFileUrl(params.fileUrl) return { filePath: fileUrl, diff --git a/apps/sim/blocks/blocks/google_slides.ts b/apps/sim/blocks/blocks/google_slides.ts index 2608eb71f1..c3540534bc 100644 --- a/apps/sim/blocks/blocks/google_slides.ts +++ b/apps/sim/blocks/blocks/google_slides.ts @@ -3369,6 +3369,7 @@ Return ONLY the text content - no explanations, no markdown formatting markers, title: { type: 'string', description: 'Presentation title' }, // Export presentation + file: { type: 'file', description: 'Stored exported presentation file' }, contentBase64: { type: 'string', description: 'Base64-encoded exported content' }, mimeType: { type: 'string', description: 'MIME type of the exported content' }, sizeBytes: { type: 'number', description: 'Size of the exported content in bytes' }, diff --git a/apps/sim/blocks/blocks/typeform.ts b/apps/sim/blocks/blocks/typeform.ts index cb707c349b..b22e1a46f4 100644 --- a/apps/sim/blocks/blocks/typeform.ts +++ b/apps/sim/blocks/blocks/typeform.ts @@ -445,6 +445,7 @@ Do not include any explanations, markdown formatting, or other text outside the message: { type: 'string', description: 'Deletion confirmation message' }, // File operation outputs fileUrl: { type: 'string', description: 'Downloaded file URL' }, + file: { type: 'file', description: 'Downloaded file' }, contentType: { type: 'string', description: 'File content type' }, filename: { type: 'string', description: 'File name' }, // Insights outputs diff --git a/apps/sim/lib/api/contracts/storage-transfer.ts b/apps/sim/lib/api/contracts/storage-transfer.ts index 45e42a7832..e4a2772622 100644 --- a/apps/sim/lib/api/contracts/storage-transfer.ts +++ b/apps/sim/lib/api/contracts/storage-transfer.ts @@ -301,7 +301,9 @@ export const fileDownloadBodySchema = z export const fileParseBodySchema = z .object({ - filePath: z.union([z.string(), z.array(z.string())]).optional(), + filePath: z + .union([z.string(), z.array(z.string()).max(10, 'At most 10 files can be parsed at once')]) + .optional(), fileType: z.string().optional().default(''), headers: z.record(z.string(), z.string()).optional(), workspaceId: z.string().optional().default(''), diff --git a/apps/sim/lib/api/contracts/tools/google.ts b/apps/sim/lib/api/contracts/tools/google.ts index 732e301ec6..d5af5ff6f6 100644 --- a/apps/sim/lib/api/contracts/tools/google.ts +++ b/apps/sim/lib/api/contracts/tools/google.ts @@ -60,6 +60,28 @@ export const googleVaultDownloadExportFileBodySchema = z.object({ fileName: z.string().optional().nullable(), }) +export const googleSlidesExportFormatSchema = z.preprocess((value) => { + if (typeof value !== 'string') return value + const normalized = value.trim().toUpperCase() + return normalized || undefined +}, z.enum(['PDF', 'PPTX', 'ODP', 'TXT', 'PNG', 'JPEG', 'SVG']).optional()) + +/** Google Drive / Slides file IDs are opaque base62-ish strings without URL metacharacters. */ +export const googlePresentationIdSchema = z + .string() + .trim() + .min(1, 'Presentation ID is required') + .regex(/^[a-zA-Z0-9_-]+$/, 'Presentation ID contains invalid characters') + +export const googleSlidesExportPresentationBodySchema = z.object({ + accessToken: googleAccessTokenSchema, + presentationId: googlePresentationIdSchema, + exportFormat: googleSlidesExportFormatSchema, + workspaceId: z.string().optional(), + workflowId: z.string().optional(), + executionId: z.string().optional(), +}) + const toolJsonResponseSchema = z.unknown() export const gmailAddLabelContract = defineRouteContract({ @@ -160,6 +182,13 @@ export const googleVaultDownloadExportFileContract = defineRouteContract({ response: { mode: 'json', schema: toolJsonResponseSchema }, }) +export const googleSlidesExportPresentationContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/google_slides/export-presentation', + body: googleSlidesExportPresentationBodySchema, + response: { mode: 'json', schema: toolJsonResponseSchema }, +}) + export type GmailAddLabelBody = ContractBodyInput export type GmailArchiveBody = ContractBodyInput export type GmailDeleteBody = ContractBodyInput @@ -176,6 +205,9 @@ export type GoogleDriveDownloadBody = ContractBodyInput +export type GoogleSlidesExportPresentationBody = ContractBodyInput< + typeof googleSlidesExportPresentationContract +> export type GmailAddLabelResponse = ContractJsonResponse export type GmailArchiveResponse = ContractJsonResponse @@ -193,3 +225,6 @@ export type GoogleDriveDownloadResponse = ContractJsonResponse +export type GoogleSlidesExportPresentationResponse = ContractJsonResponse< + typeof googleSlidesExportPresentationContract +> diff --git a/apps/sim/lib/api/contracts/tools/index.ts b/apps/sim/lib/api/contracts/tools/index.ts index 691b408d9a..9fb5e570b8 100644 --- a/apps/sim/lib/api/contracts/tools/index.ts +++ b/apps/sim/lib/api/contracts/tools/index.ts @@ -24,5 +24,6 @@ export * from './search' export * from './shared' export * from './stagehand' export * from './thinking' +export * from './typeform' export * from './workday' export * from './zoom' diff --git a/apps/sim/lib/api/contracts/tools/typeform.ts b/apps/sim/lib/api/contracts/tools/typeform.ts new file mode 100644 index 0000000000..ddf263b8e5 --- /dev/null +++ b/apps/sim/lib/api/contracts/tools/typeform.ts @@ -0,0 +1,22 @@ +import { z } from 'zod' +import { genericToolResponseSchema } from '@/lib/api/contracts/tools/shared' +import { defineRouteContract } from '@/lib/api/contracts/types' + +export const typeformFilesBodySchema = z.object({ + formId: z.string().min(1, 'Form ID is required'), + responseId: z.string().min(1, 'Response ID is required'), + fieldId: z.string().min(1, 'Field ID is required'), + filename: z.string().min(1, 'Filename is required'), + inline: z.boolean().optional(), + apiKey: z.string().min(1, 'API key is required'), + workspaceId: z.string().optional(), + workflowId: z.string().optional(), + executionId: z.string().optional(), +}) + +export const typeformFilesContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/typeform/files', + body: typeformFilesBodySchema, + response: { mode: 'json', schema: genericToolResponseSchema }, +}) diff --git a/apps/sim/lib/billing/cleanup-dispatcher.ts b/apps/sim/lib/billing/cleanup-dispatcher.ts index 23c410ee36..cb1704f0d8 100644 --- a/apps/sim/lib/billing/cleanup-dispatcher.ts +++ b/apps/sim/lib/billing/cleanup-dispatcher.ts @@ -3,7 +3,7 @@ import type { WorkspaceMode } from '@sim/db/schema' import { organization, workspace } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { tasks } from '@trigger.dev/sdk' -import { eq, isNull } from 'drizzle-orm' +import { and, asc, eq, gt, isNull } from 'drizzle-orm' import { getOrganizationSubscription } from '@/lib/billing/core/billing' import { getHighestPriorityPersonalSubscription } from '@/lib/billing/core/subscription' import { getPlanType, type PlanCategory } from '@/lib/billing/plan-helpers' @@ -18,6 +18,7 @@ const logger = createLogger('RetentionDispatcher') /** Trigger.dev's documented cap on items per `batchTrigger` call (SDK 4.3.1+). */ const BATCH_TRIGGER_CHUNK_SIZE = 1000 +const WORKSPACE_SCOPE_PAGE_SIZE = 500 /** Bounds per-run memory + DB connections regardless of plan size. */ const WORKSPACES_PER_CLEANUP_CHUNK = 500 @@ -63,6 +64,10 @@ const DAY = 24 type PlanResolutionEntry = readonly [string, PlanCategory] +function getCleanupConcurrencyKey(jobType: CleanupJobType): string { + return `cleanup:${jobType}` +} + /** * Single source of truth for cleanup retention: which key each job type reads * from `organization.dataRetentionSettings`, and the default retention (in @@ -84,7 +89,9 @@ export const CLEANUP_CONFIG = { }, } as const satisfies Record -async function listActiveWorkspaceCleanupScopeRows(): Promise { +async function listActiveWorkspaceCleanupScopeRowsPage( + afterId: string | null +): Promise { const rows = await db .select({ id: workspace.id, @@ -95,7 +102,13 @@ async function listActiveWorkspaceCleanupScopeRows(): Promise ({ ...row, @@ -199,64 +212,85 @@ const GLOBAL_HOUSEKEEPING_PLAN: Partial> = 'cleanup-logs': 'free', } -async function buildCleanupChunks(jobType: CleanupJobType): Promise { +async function forEachCleanupChunk( + jobType: CleanupJobType, + onChunk: (payload: CleanupJobPayload) => Promise +): Promise<{ chunkCount: number; workspaceCount: number }> { const config = CLEANUP_CONFIG[jobType] - const activeRows = await listActiveWorkspaceCleanupScopeRows() - const planByWorkspaceId = await resolvePlanTypesByWorkspaceId(activeRows) - - const chunks: CleanupJobPayload[] = [] - - for (const plan of NON_ENTERPRISE_PLANS) { - const retentionHours = config.defaults[plan] - if (retentionHours === null) continue - const workspaceIds = activeRows - .filter((row) => planByWorkspaceId.get(row.id) === plan) - .map((row) => row.id) - if (workspaceIds.length === 0) continue - const planChunks = chunkArray(workspaceIds, WORKSPACES_PER_CLEANUP_CHUNK) - for (const [idx, ws] of planChunks.entries()) { - chunks.push({ - plan, - workspaceIds: ws, - retentionHours, - label: planChunks.length > 1 ? `${plan}/${idx + 1}` : plan, - }) + const chunkCountByPlan: Partial> = {} + const housekeepingPlan = GLOBAL_HOUSEKEEPING_PLAN[jobType] + let housekeepingAssigned = false + let workspaceCount = 0 + let chunkCount = 0 + let afterId: string | null = null + + const emitChunk = async (payload: CleanupJobPayload) => { + if (payload.plan === housekeepingPlan && !housekeepingAssigned) { + payload.runGlobalHousekeeping = true + housekeepingAssigned = true } + chunkCount++ + await onChunk(payload) } - for (const row of activeRows) { - if (planByWorkspaceId.get(row.id) !== 'enterprise') continue - const hours = row.organizationSettings?.[config.key] - if (hours == null) continue - chunks.push({ - plan: 'enterprise', - workspaceIds: [row.id], - retentionHours: hours, - label: `enterprise/${row.id}`, - }) - } - - const housekeepingPlan = GLOBAL_HOUSEKEEPING_PLAN[jobType] - if (housekeepingPlan) { - const target = chunks.find((c) => c.plan === housekeepingPlan) - if (target) { - target.runGlobalHousekeeping = true - } else if (housekeepingPlan !== 'enterprise') { - // Synthetic empty chunk so housekeeping still fires when the plan has no workspaces. - const retentionHours = config.defaults[housekeepingPlan] - if (retentionHours != null) { - chunks.push({ - plan: housekeepingPlan, - workspaceIds: [], + while (true) { + const rows = await listActiveWorkspaceCleanupScopeRowsPage(afterId) + if (rows.length === 0) break + + afterId = rows[rows.length - 1].id + const planByWorkspaceId = await resolvePlanTypesByWorkspaceId(rows) + + for (const plan of NON_ENTERPRISE_PLANS) { + const retentionHours = config.defaults[plan] + if (retentionHours === null) continue + + const workspaceIds = rows + .filter((row) => planByWorkspaceId.get(row.id) === plan) + .map((row) => row.id) + if (workspaceIds.length === 0) continue + + workspaceCount += workspaceIds.length + const planChunks = chunkArray(workspaceIds, WORKSPACES_PER_CLEANUP_CHUNK) + for (const ws of planChunks) { + const chunkNumber = (chunkCountByPlan[plan] ?? 0) + 1 + chunkCountByPlan[plan] = chunkNumber + await emitChunk({ + plan, + workspaceIds: ws, retentionHours, - label: `${housekeepingPlan}/housekeeping`, - runGlobalHousekeeping: true, + label: `${plan}/${chunkNumber}`, }) } } + + for (const row of rows) { + if (planByWorkspaceId.get(row.id) !== 'enterprise') continue + const hours = row.organizationSettings?.[config.key] + if (hours == null) continue + workspaceCount++ + await emitChunk({ + plan: 'enterprise', + workspaceIds: [row.id], + retentionHours: hours, + label: `enterprise/${row.id}`, + }) + } + } + + if (housekeepingPlan && housekeepingPlan !== 'enterprise' && !housekeepingAssigned) { + const retentionHours = config.defaults[housekeepingPlan] + if (retentionHours != null) { + await emitChunk({ + plan: housekeepingPlan, + workspaceIds: [], + retentionHours, + label: `${housekeepingPlan}/housekeeping`, + runGlobalHousekeeping: true, + }) + } } - return chunks + return { chunkCount, workspaceCount } } /** @@ -270,55 +304,79 @@ export async function dispatchCleanupJobs(jobType: CleanupJobType): Promise<{ chunkCount: number workspaceCount: number }> { - const chunks = await buildCleanupChunks(jobType) - const workspaceCount = chunks.reduce((sum, c) => sum + c.workspaceIds.length, 0) - - logger.info( - `[${jobType}] Dispatching: ${chunks.length} chunk(s) covering ${workspaceCount} workspace(s)` - ) - - if (chunks.length === 0) { - return { jobIds: [], jobCount: 0, chunkCount: 0, workspaceCount: 0 } - } - const jobIds: string[] = [] + let succeeded = 0 + let failed = 0 if (isTriggerAvailable()) { - for (let i = 0; i < chunks.length; i += BATCH_TRIGGER_CHUNK_SIZE) { - const batch = chunks.slice(i, i + BATCH_TRIGGER_CHUNK_SIZE) + let batch: CleanupJobPayload[] = [] + const flushBatch = async () => { + if (batch.length === 0) return + const currentBatch = batch + batch = [] const batchResult = await tasks.batchTrigger( jobType, - batch.map((payload) => ({ + currentBatch.map((payload) => ({ payload, options: { tags: [`plan:${payload.plan}`, `jobType:${jobType}`], + concurrencyKey: getCleanupConcurrencyKey(jobType), }, })) ) jobIds.push(batchResult.batchId) + succeeded += currentBatch.length } - return { jobIds, jobCount: jobIds.length, chunkCount: chunks.length, workspaceCount } + + const { chunkCount, workspaceCount } = await forEachCleanupChunk(jobType, async (payload) => { + batch.push(payload) + if (batch.length >= BATCH_TRIGGER_CHUNK_SIZE) { + await flushBatch() + } + }) + await flushBatch() + + logger.info( + `[${jobType}] Trigger cleanup chunks: ${succeeded} dispatched in ${jobIds.length} batch(es)` + ) + return { jobIds, jobCount: jobIds.length, chunkCount, workspaceCount } } - // Fallback: parallel enqueue via abstraction (self-hosted / inline path) const inlineRunner = shouldExecuteInline() ? await buildCleanupRunner(jobType) : undefined - const jobQueue = await getJobQueue() - const results = await Promise.allSettled( - chunks.map((payload) => jobQueue.enqueue(jobType, payload, { runner: inlineRunner })) - ) + if (inlineRunner) { + const { chunkCount, workspaceCount } = await forEachCleanupChunk(jobType, async (payload) => { + try { + await inlineRunner(payload, new AbortController().signal) + jobIds.push(`inline:${jobType}:${payload.label}`) + succeeded++ + } catch (error) { + failed++ + logger.error(`[${jobType}] Inline cleanup chunk failed:`, { + plan: payload.plan, + label: payload.label, + error, + }) + } + }) - let succeeded = 0 - let failed = 0 - for (const result of results) { - if (result.status === 'fulfilled') { - jobIds.push(result.value) + logger.info(`[${jobType}] Inline cleanup chunks: ${succeeded} succeeded, ${failed} failed`) + return { jobIds, jobCount: jobIds.length, chunkCount, workspaceCount } + } + + const jobQueue = await getJobQueue() + const { chunkCount, workspaceCount } = await forEachCleanupChunk(jobType, async (payload) => { + try { + const jobId = await jobQueue.enqueue(jobType, payload, { + concurrencyKey: getCleanupConcurrencyKey(jobType), + }) + jobIds.push(jobId) succeeded++ - } else { + } catch (reason) { failed++ - logger.error(`[${jobType}] Failed to enqueue chunk:`, { reason: result.reason }) + logger.error(`[${jobType}] Failed to enqueue chunk:`, { reason }) } - } + }) logger.info(`[${jobType}] Chunk enqueue: ${succeeded} succeeded, ${failed} failed`) - return { jobIds, jobCount: jobIds.length, chunkCount: chunks.length, workspaceCount } + return { jobIds, jobCount: jobIds.length, chunkCount, workspaceCount } } diff --git a/apps/sim/lib/core/security/input-validation.server.ts b/apps/sim/lib/core/security/input-validation.server.ts index e16bda7c6e..d76928ade8 100644 --- a/apps/sim/lib/core/security/input-validation.server.ts +++ b/apps/sim/lib/core/security/input-validation.server.ts @@ -7,6 +7,7 @@ import { toError } from '@sim/utils/errors' import * as ipaddr from 'ipaddr.js' import { isHosted } from '@/lib/core/config/feature-flags' import { type ValidationResult, validateExternalUrl } from '@/lib/core/security/input-validation' +import { PayloadSizeLimitError } from '@/lib/core/utils/stream-limits' const logger = createLogger('InputValidation') @@ -263,6 +264,10 @@ function isRedirectStatus(status: number): boolean { return status >= 300 && status < 400 && status !== 304 } +function isRetryableHttpStatus(status: number): boolean { + return status === 429 || (status >= 500 && status <= 599) +} + function resolveRedirectUrl(baseUrl: string, location: string): string { try { return new URL(location, baseUrl).toString() @@ -381,6 +386,37 @@ export async function secureFetchWithPinnedIP( } } + const contentLength = headersRecord['content-length'] + if (typeof maxResponseBytes === 'number' && maxResponseBytes > 0 && contentLength) { + const parsedLength = Number.parseInt(contentLength, 10) + if (Number.isFinite(parsedLength) && parsedLength > maxResponseBytes) { + cleanupAbort() + res.destroy() + req.destroy() + if (isRetryableHttpStatus(statusCode)) { + settledResolve({ + ok: false, + status: statusCode, + statusText: res.statusMessage || '', + headers: new SecureFetchHeaders(headersRecord, setCookieArray), + body: null, + text: async () => '', + json: async () => ({}), + arrayBuffer: async () => new ArrayBuffer(0), + }) + return + } + settledReject( + new PayloadSizeLimitError({ + label: 'response body', + maxBytes: maxResponseBytes, + observedBytes: parsedLength, + }) + ) + return + } + } + let totalBytes = 0 const nodeRes = res const body = new ReadableStream({ @@ -394,7 +430,11 @@ export async function secureFetchWithPinnedIP( ) { cleanupAbort() controller.error( - new Error(`Response exceeded maximum size of ${maxResponseBytes} bytes`) + new PayloadSizeLimitError({ + label: 'response body', + maxBytes: maxResponseBytes, + observedBytes: totalBytes, + }) ) nodeRes.destroy() return diff --git a/apps/sim/lib/core/utils/stream-limits.test.ts b/apps/sim/lib/core/utils/stream-limits.test.ts new file mode 100644 index 0000000000..ae6e9b425d --- /dev/null +++ b/apps/sim/lib/core/utils/stream-limits.test.ts @@ -0,0 +1,232 @@ +/** + * @vitest-environment node + */ + +import { Readable } from 'stream' +import { describe, expect, it, vi } from 'vitest' +import { + assertContentLengthWithinLimit, + PayloadSizeLimitError, + readFileToBufferWithLimit, + readFormDataWithLimit, + readNodeStreamToBufferWithLimit, + readResponseJsonWithLimit, + readResponseTextWithLimit, + readResponseToBufferWithLimit, + readStreamToBufferWithLimit, +} from '@/lib/core/utils/stream-limits' + +function streamFromChunks(chunks: Uint8Array[]): ReadableStream { + let index = 0 + return new ReadableStream({ + pull(controller) { + if (index >= chunks.length) { + controller.close() + return + } + controller.enqueue(chunks[index]) + index += 1 + }, + }) +} + +function headers(contentLength?: string): Headers { + const headers = new Headers() + if (contentLength !== undefined) headers.set('content-length', contentLength) + return headers +} + +describe('stream limits', () => { + it('reads a stream under the limit', async () => { + const buffer = await readStreamToBufferWithLimit( + streamFromChunks([new TextEncoder().encode('hello'), new TextEncoder().encode(' world')]), + { maxBytes: 32, label: 'test payload' } + ) + + expect(buffer.toString('utf-8')).toBe('hello world') + }) + + it('rejects when content-length is over the limit', () => { + expect(() => assertContentLengthWithinLimit(headers('11'), 10, 'download')).toThrow( + PayloadSizeLimitError + ) + }) + + it('cancels response bodies when content-length preflight rejects', async () => { + const cancelSpy = vi.fn() + const body = new ReadableStream({ + cancel: cancelSpy, + }) + + await expect( + readResponseToBufferWithLimit( + { + headers: headers('11'), + body, + }, + { maxBytes: 10, label: 'download' } + ) + ).rejects.toBeInstanceOf(PayloadSizeLimitError) + expect(cancelSpy).toHaveBeenCalled() + }) + + it('allows content-length exactly at the limit', () => { + expect(() => assertContentLengthWithinLimit(headers('10'), 10, 'download')).not.toThrow() + }) + + it('rejects when streamed bytes exceed the limit', async () => { + await expect( + readStreamToBufferWithLimit(streamFromChunks([new Uint8Array(6), new Uint8Array(5)]), { + maxBytes: 10, + label: 'download', + }) + ).rejects.toMatchObject({ + name: 'PayloadSizeLimitError', + maxBytes: 10, + observedBytes: 11, + }) + }) + + it('rejects underreported content-length via streamed byte counting', async () => { + await expect( + readResponseToBufferWithLimit( + { + headers: headers('5'), + body: streamFromChunks([new Uint8Array(6), new Uint8Array(5)]), + }, + { maxBytes: 10, label: 'download' } + ) + ).rejects.toBeInstanceOf(PayloadSizeLimitError) + }) + + it('returns an empty buffer for a missing body', async () => { + const buffer = await readResponseToBufferWithLimit( + { headers: headers('0'), body: null }, + { maxBytes: 10, label: 'empty response' } + ) + + expect(buffer.length).toBe(0) + }) + + it('reads text and JSON responses with limits', async () => { + const text = await readResponseTextWithLimit( + { body: streamFromChunks([new TextEncoder().encode('hello')]) }, + { maxBytes: 10, label: 'text response' } + ) + const json = await readResponseJsonWithLimit<{ ok: boolean }>( + { body: streamFromChunks([new TextEncoder().encode('{"ok":true}')]) }, + { maxBytes: 20, label: 'json response' } + ) + + expect(text).toBe('hello') + expect(json.ok).toBe(true) + }) + + it('prefers arrayBuffer over text for binary response fallbacks', async () => { + const bytes = Uint8Array.from([0, 255, 1, 254]) + const arrayBuffer = vi.fn(async () => bytes.buffer) + const text = vi.fn(async () => 'corrupted') + + const buffer = await readResponseToBufferWithLimit( + { headers: headers(String(bytes.byteLength)), arrayBuffer, text }, + { maxBytes: 10, label: 'binary response' } + ) + + expect(buffer).toEqual(Buffer.from(bytes)) + expect(arrayBuffer).toHaveBeenCalled() + expect(text).not.toHaveBeenCalled() + }) + + it('rejects no-body response fallbacks without a trusted content-length', async () => { + await expect( + readResponseToBufferWithLimit( + { + arrayBuffer: vi.fn(async () => new Uint8Array(1024).buffer), + }, + { maxBytes: 10, label: 'unknown response' } + ) + ).rejects.toBeInstanceOf(PayloadSizeLimitError) + }) + + it('cancels when the abort signal is already aborted', async () => { + const controller = new AbortController() + controller.abort(new Error('stop')) + const cancelSpy = vi.fn() + const stream = new ReadableStream({ + pull(controller) { + controller.enqueue(new TextEncoder().encode('content')) + }, + cancel: cancelSpy, + }) + + await expect( + readStreamToBufferWithLimit(stream, { + maxBytes: 100, + label: 'abortable', + signal: controller.signal, + }) + ).rejects.toThrow('stop') + expect(cancelSpy).toHaveBeenCalled() + }) + + it('checks file size before materializing a File', async () => { + const file = new File(['hello'], 'small.txt', { type: 'text/plain' }) + const buffer = await readFileToBufferWithLimit(file, { maxBytes: 5, label: 'upload file' }) + + expect(buffer.toString('utf-8')).toBe('hello') + await expect( + readFileToBufferWithLimit(file, { maxBytes: 4, label: 'upload file' }) + ).rejects.toBeInstanceOf(PayloadSizeLimitError) + }) + + it('parses multipart form data without requiring content-length', async () => { + const input = new FormData() + input.append('name', 'example') + const request = new Request('http://localhost/upload', { + method: 'POST', + body: input, + }) + + expect(request.headers.get('content-length')).toBeNull() + + const formData = await readFormDataWithLimit(request, { + maxBytes: 1024 * 1024, + label: 'multipart body', + }) + + expect(formData.get('name')).toBe('example') + }) + + it('rejects multipart streams without content-length once bytes exceed the limit', async () => { + const request = new Request('http://localhost/upload', { + method: 'POST', + headers: { 'content-type': 'multipart/form-data; boundary=test' }, + body: streamFromChunks([new Uint8Array(6), new Uint8Array(5)]), + duplex: 'half', + } as RequestInit) + + await expect( + readFormDataWithLimit(request, { maxBytes: 10, label: 'multipart body' }) + ).rejects.toBeInstanceOf(PayloadSizeLimitError) + }) + + it('rechecks materialized file bytes after arrayBuffer', async () => { + const file = { + size: 1, + arrayBuffer: vi.fn(async () => new Uint8Array(6).buffer), + } as unknown as File + + await expect( + readFileToBufferWithLimit(file, { maxBytes: 5, label: 'upload file' }) + ).rejects.toBeInstanceOf(PayloadSizeLimitError) + }) + + it('rejects node streams that exceed the limit', async () => { + await expect( + readNodeStreamToBufferWithLimit(Readable.from([Buffer.alloc(6), Buffer.alloc(5)]), { + maxBytes: 10, + label: 'storage download', + }) + ).rejects.toBeInstanceOf(PayloadSizeLimitError) + }) +}) diff --git a/apps/sim/lib/core/utils/stream-limits.ts b/apps/sim/lib/core/utils/stream-limits.ts new file mode 100644 index 0000000000..06bd7c0f65 --- /dev/null +++ b/apps/sim/lib/core/utils/stream-limits.ts @@ -0,0 +1,321 @@ +import { toError } from '@sim/utils/errors' + +export const DEFAULT_MAX_ERROR_BODY_BYTES = 64 * 1024 + +export interface PayloadSizeLimitContext { + label: string + maxBytes: number + observedBytes?: number +} + +export class PayloadSizeLimitError extends Error { + readonly label: string + readonly maxBytes: number + readonly observedBytes?: number + + constructor({ label, maxBytes, observedBytes }: PayloadSizeLimitContext) { + super( + observedBytes === undefined + ? `${label} exceeds maximum size of ${maxBytes} bytes` + : `${label} exceeds maximum size of ${maxBytes} bytes (${observedBytes} bytes received)` + ) + this.name = 'PayloadSizeLimitError' + this.label = label + this.maxBytes = maxBytes + this.observedBytes = observedBytes + } +} + +export function isPayloadSizeLimitError(error: unknown): error is PayloadSizeLimitError { + return error instanceof PayloadSizeLimitError +} + +export function assertKnownSizeWithinLimit(size: number, maxBytes: number, label: string): void { + if (Number.isFinite(size) && size > maxBytes) { + throw new PayloadSizeLimitError({ label, maxBytes, observedBytes: size }) + } +} + +function getContentLength( + headers: { get(name: string): string | null } | undefined +): number | null { + const rawLength = headers?.get('content-length') + if (!rawLength) return null + const parsed = Number.parseInt(rawLength, 10) + return Number.isFinite(parsed) && parsed >= 0 ? parsed : null +} + +export function assertContentLengthWithinLimit( + headers: { get(name: string): string | null } | undefined, + maxBytes: number, + label: string +): void { + const contentLength = getContentLength(headers) + if (contentLength !== null) { + assertKnownSizeWithinLimit(contentLength, maxBytes, label) + } +} + +export interface ReadFormDataWithLimitRequest { + url: string + method: string + headers?: Headers + body?: ReadableStream | null + formData: () => Promise +} + +export async function readFormDataWithLimit( + request: ReadFormDataWithLimitRequest, + options: { maxBytes: number; label: string } +): Promise { + assertContentLengthWithinLimit(request.headers, options.maxBytes, options.label) + + if (request.headers?.get('content-length') || !request.body) { + return request.formData() + } + + const body = await readStreamToBufferWithLimit(request.body, options) + const boundedRequest = new Request(request.url, { + method: request.method, + headers: request.headers, + body: new Uint8Array(body), + }) + return boundedRequest.formData() +} + +export interface ReadStreamWithLimitOptions { + maxBytes: number + label: string + signal?: AbortSignal + onChunk?: (chunk: Uint8Array, totalBytes: number) => void | Promise +} + +export async function readStreamToBufferWithLimit( + stream: ReadableStream | null | undefined, + options: ReadStreamWithLimitOptions +): Promise { + if (!stream) return Buffer.alloc(0) + + const reader = stream.getReader() + const chunks: Buffer[] = [] + let totalBytes = 0 + + try { + while (true) { + if (options.signal?.aborted) { + await reader.cancel(options.signal.reason).catch(() => {}) + throw toError(options.signal.reason ?? new Error('Aborted')) + } + + const { done, value } = await reader.read() + if (done) break + if (!value) continue + + totalBytes += value.byteLength + if (totalBytes > options.maxBytes) { + await reader.cancel().catch(() => {}) + throw new PayloadSizeLimitError({ + label: options.label, + maxBytes: options.maxBytes, + observedBytes: totalBytes, + }) + } + + await options.onChunk?.(value, totalBytes) + chunks.push(Buffer.from(value)) + } + } finally { + reader.releaseLock() + } + + return Buffer.concat(chunks, totalBytes) +} + +export async function readNodeStreamToBufferWithLimit( + stream: NodeJS.ReadableStream | null | undefined, + options: ReadStreamWithLimitOptions +): Promise { + if (!stream) return Buffer.alloc(0) + + return new Promise((resolve, reject) => { + const chunks: Buffer[] = [] + let totalBytes = 0 + let settled = false + + const finish = (callback: () => void) => { + if (settled) return + settled = true + cleanup() + callback() + } + + const cleanup = () => { + stream.off('data', onData) + stream.off('end', onEnd) + stream.off('error', onError) + options.signal?.removeEventListener('abort', onAbort) + } + + const onAbort = () => { + if ('destroy' in stream && typeof stream.destroy === 'function') { + stream.destroy(toError(options.signal?.reason ?? new Error('Aborted'))) + } + finish(() => reject(toError(options.signal?.reason ?? new Error('Aborted')))) + } + + const onData = (chunk: Buffer | Uint8Array | string) => { + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk) + totalBytes += buffer.byteLength + + if (totalBytes > options.maxBytes) { + if ('destroy' in stream && typeof stream.destroy === 'function') { + stream.destroy() + } + finish(() => + reject( + new PayloadSizeLimitError({ + label: options.label, + maxBytes: options.maxBytes, + observedBytes: totalBytes, + }) + ) + ) + return + } + + void options.onChunk?.(buffer, totalBytes) + chunks.push(buffer) + } + + const onEnd = () => { + finish(() => resolve(Buffer.concat(chunks, totalBytes))) + } + + const onError = (error: unknown) => { + finish(() => reject(error)) + } + + if (options.signal?.aborted) { + onAbort() + return + } + + options.signal?.addEventListener('abort', onAbort, { once: true }) + stream.on('data', onData) + stream.on('end', onEnd) + stream.on('error', onError) + }) +} + +export interface ReadResponseWithLimitOptions extends ReadStreamWithLimitOptions { + headers?: { get(name: string): string | null } + preferTextFallback?: boolean + allowNoBodyFallback?: boolean +} + +export async function readResponseToBufferWithLimit( + response: { + headers?: { get(name: string): string | null } + body?: ReadableStream | null + arrayBuffer?: () => Promise + text?: () => Promise + }, + options: ReadResponseWithLimitOptions +): Promise { + const contentLength = getContentLength(response.headers ?? options.headers) + try { + if (contentLength !== null) { + assertKnownSizeWithinLimit(contentLength, options.maxBytes, options.label) + } + } catch (error) { + if (isPayloadSizeLimitError(error)) { + await response.body?.cancel(error).catch(() => {}) + } + throw error + } + if ( + !options.allowNoBodyFallback && + !response.body && + contentLength === null && + (response.arrayBuffer || response.text) + ) { + throw new PayloadSizeLimitError({ + label: options.label, + maxBytes: options.maxBytes, + }) + } + if (!response.body && options.preferTextFallback && response.text) { + const text = await response.text() + const buffer = Buffer.from(text) + assertKnownSizeWithinLimit(buffer.byteLength, options.maxBytes, options.label) + return buffer + } + if (!response.body && response.arrayBuffer) { + const buffer = Buffer.from(await response.arrayBuffer()) + assertKnownSizeWithinLimit(buffer.byteLength, options.maxBytes, options.label) + if (buffer.byteLength > 0 || !response.text) { + return buffer + } + const text = await response.text() + const textBuffer = Buffer.from(text) + assertKnownSizeWithinLimit(textBuffer.byteLength, options.maxBytes, options.label) + return textBuffer + } + if (!response.body && response.text) { + const text = await response.text() + const buffer = Buffer.from(text) + assertKnownSizeWithinLimit(buffer.byteLength, options.maxBytes, options.label) + return buffer + } + return readStreamToBufferWithLimit(response.body, options) +} + +export async function readResponseTextWithLimit( + response: { + headers?: { get(name: string): string | null } + body?: ReadableStream | null + arrayBuffer?: () => Promise + text?: () => Promise + }, + options: ReadResponseWithLimitOptions +): Promise { + return ( + await readResponseToBufferWithLimit(response, { ...options, preferTextFallback: true }) + ).toString('utf-8') +} + +export async function readResponseJsonWithLimit( + response: { + headers?: { get(name: string): string | null } + body?: ReadableStream | null + }, + options: ReadResponseWithLimitOptions +): Promise { + return JSON.parse(await readResponseTextWithLimit(response, options)) as T +} + +export async function readFileToBufferWithLimit( + file: File, + options: { maxBytes: number; label: string } +): Promise { + assertKnownSizeWithinLimit(file.size, options.maxBytes, options.label) + const buffer = Buffer.from(await file.arrayBuffer()) + assertKnownSizeWithinLimit(buffer.byteLength, options.maxBytes, options.label) + return buffer +} + +export async function consumeOrCancelBody( + response: { body?: ReadableStream | null }, + maxBytes = DEFAULT_MAX_ERROR_BODY_BYTES +): Promise { + if (!response.body) return + + try { + await readStreamToBufferWithLimit(response.body, { + maxBytes, + label: 'response body', + }) + } catch { + await response.body.cancel().catch(() => {}) + } +} diff --git a/apps/sim/lib/execution/payloads/materialization.server.ts b/apps/sim/lib/execution/payloads/materialization.server.ts index 0a7a8e3857..58093bed71 100644 --- a/apps/sim/lib/execution/payloads/materialization.server.ts +++ b/apps/sim/lib/execution/payloads/materialization.server.ts @@ -1,5 +1,6 @@ import { createLogger, type Logger } from '@sim/logger' import { toError } from '@sim/utils/errors' +import { isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits' import { isUserFileWithMetadata } from '@/lib/core/utils/user-file' import { getLargeValueMaterializationError, @@ -132,22 +133,31 @@ export async function readLargeValueRefFromStorage( assertLargeValueRefAccess(ref, options) assertInlineMaterializationSize(ref.size, options.maxBytes) + const maxBytes = options.maxBytes ?? MAX_INLINE_MATERIALIZATION_BYTES try { const { StorageService } = await import('@/lib/uploads') const buffer = await StorageService.downloadFile({ key: ref.key, context: 'execution', + maxBytes, }) - if (buffer.length > (options.maxBytes ?? MAX_INLINE_MATERIALIZATION_BYTES)) { + if (buffer.length > maxBytes) { throw new ExecutionResourceLimitError({ resource: 'execution_payload_bytes', attemptedBytes: buffer.length, - limitBytes: options.maxBytes ?? MAX_INLINE_MATERIALIZATION_BYTES, + limitBytes: maxBytes, }) } return JSON.parse(buffer.toString('utf8')) } catch (error) { + if (isPayloadSizeLimitError(error)) { + throw new ExecutionResourceLimitError({ + resource: 'execution_payload_bytes', + attemptedBytes: error.observedBytes ?? maxBytes + 1, + limitBytes: maxBytes, + }) + } if (error instanceof ExecutionResourceLimitError) { throw error } @@ -280,7 +290,18 @@ export async function readUserFileContent( const log = getLogger(options) const requestId = options.requestId ?? 'unknown' - buffer = await downloadFileFromStorage(file, requestId, log) + try { + buffer = await downloadFileFromStorage(file, requestId, log, { maxBytes: maxSourceBytes }) + } catch (error) { + if (isPayloadSizeLimitError(error)) { + throw new ExecutionResourceLimitError({ + resource: 'execution_payload_bytes', + attemptedBytes: error.observedBytes ?? maxSourceBytes + 1, + limitBytes: maxSourceBytes, + }) + } + throw error + } if (!buffer) { throw new Error(`File content for ${file.name} is unavailable.`) diff --git a/apps/sim/lib/execution/payloads/store.test.ts b/apps/sim/lib/execution/payloads/store.test.ts index 089f8284f0..3b08d38253 100644 --- a/apps/sim/lib/execution/payloads/store.test.ts +++ b/apps/sim/lib/execution/payloads/store.test.ts @@ -2,6 +2,7 @@ * @vitest-environment node */ import { beforeEach, describe, expect, it, vi } from 'vitest' +import { PayloadSizeLimitError } from '@/lib/core/utils/stream-limits' import { cacheLargeValue, clearLargeValueCacheForTests, @@ -28,6 +29,11 @@ vi.mock('@/lib/uploads', () => ({ }, })) +vi.mock('@/lib/uploads/core/storage-service', () => ({ + uploadFile: mockUploadFile, + downloadFile: mockDownloadFile, +})) + vi.mock('@/app/api/files/authorization', () => ({ verifyFileAccess: mockVerifyFileAccess, })) @@ -429,6 +435,82 @@ describe('large execution payload store', () => { ).rejects.toMatchObject({ code: EXECUTION_RESOURCE_LIMIT_CODE }) }) + it('passes source byte limits into execution file storage downloads', async () => { + const workspaceId = '11111111-1111-4111-8111-111111111111' + const workflowId = '22222222-2222-4222-8222-222222222222' + const executionId = '33333333-3333-4333-8333-333333333333' + mockDownloadFile.mockResolvedValueOnce(Buffer.from('hello', 'utf8')) + + await expect( + readUserFileContent( + { + id: 'file_1', + name: 'hello.txt', + url: `/api/files/serve/execution/${workspaceId}/${workflowId}/${executionId}/hello.txt`, + key: `execution/${workspaceId}/${workflowId}/${executionId}/hello.txt`, + context: 'execution', + size: 5, + type: 'text/plain', + }, + { + workspaceId, + workflowId, + executionId, + userId: 'user-1', + encoding: 'text', + maxSourceBytes: 6, + } + ) + ).resolves.toBe('hello') + + expect(mockDownloadFile).toHaveBeenCalledWith( + expect.objectContaining({ + key: `execution/${workspaceId}/${workflowId}/${executionId}/hello.txt`, + context: 'execution', + maxBytes: 6, + }) + ) + }) + + it('converts storage byte-limit failures into execution resource-limit errors', async () => { + const workspaceId = '11111111-1111-4111-8111-111111111111' + const workflowId = '22222222-2222-4222-8222-222222222222' + const executionId = '33333333-3333-4333-8333-333333333333' + mockDownloadFile.mockRejectedValueOnce( + new PayloadSizeLimitError({ + label: 'storage file download', + maxBytes: 6, + observedBytes: 7, + }) + ) + + await expect( + readUserFileContent( + { + id: 'file_1', + name: 'hello.txt', + url: `/api/files/serve/execution/${workspaceId}/${workflowId}/${executionId}/hello.txt`, + key: `execution/${workspaceId}/${workflowId}/${executionId}/hello.txt`, + context: 'execution', + size: 5, + type: 'text/plain', + }, + { + workspaceId, + workflowId, + executionId, + userId: 'user-1', + encoding: 'text', + maxSourceBytes: 6, + } + ) + ).rejects.toMatchObject({ + code: EXECUTION_RESOURCE_LIMIT_CODE, + attemptedBytes: 7, + limitBytes: 6, + }) + }) + it('allows explicit chunked file reads to slice within the inline cap', async () => { const workspaceId = '11111111-1111-4111-8111-111111111111' const workflowId = '22222222-2222-4222-8222-222222222222' diff --git a/apps/sim/lib/logs/execution/logger.test.ts b/apps/sim/lib/logs/execution/logger.test.ts index 4f4bb7ff73..645168c232 100644 --- a/apps/sim/lib/logs/execution/logger.test.ts +++ b/apps/sim/lib/logs/execution/logger.test.ts @@ -168,6 +168,97 @@ describe('ExecutionLogger', () => { expect(completedData.hasTraceSpans).toBe(false) expect(completedData.traceSpanCount).toBe(0) }) + + test('summarizes oversized execution data before storage', () => { + const loggerInstance = new ExecutionLogger() as any + const largePayload = 'x'.repeat(220_000) + const executionState = { + blockStates: { + blockA: { + output: { data: largePayload }, + executed: true, + executionTime: 10, + }, + }, + executedBlocks: ['blockA'], + blockLogs: [ + { + blockId: 'blockA', + blockName: 'HTTP', + blockType: 'api', + startedAt: '2025-01-01T00:00:00.000Z', + endedAt: '2025-01-01T00:00:01.000Z', + durationMs: 1000, + success: true, + executionOrder: 1, + input: { url: 'https://example.com/image.jpg', data: largePayload }, + output: { data: largePayload }, + }, + ], + decisions: { router: {}, condition: {} }, + completedLoops: [], + activeExecutionPath: [], + } + + const completedData = loggerInstance.buildCompletedExecutionData({ + traceSpans: [ + { + id: 'workflow-execution', + name: 'Workflow Execution', + type: 'workflow', + duration: 1000, + startTime: '2025-01-01T00:00:00.000Z', + endTime: '2025-01-01T00:00:01.000Z', + status: 'success', + children: [ + { + id: 'blockA-1', + name: 'HTTP', + type: 'api', + duration: 1000, + startTime: '2025-01-01T00:00:00.000Z', + endTime: '2025-01-01T00:00:01.000Z', + status: 'success', + blockId: 'blockA', + executionOrder: 1, + input: { url: 'https://example.com/image.jpg', data: largePayload }, + output: { data: largePayload }, + }, + ], + }, + ], + finalOutput: { data: largePayload }, + executionState, + finalizationPath: 'completed', + executionCost: { + tokens: { input: 0, output: 0, total: 0 }, + models: {}, + }, + }) + + const compacted = loggerInstance.compactExecutionDataForStorage( + completedData, + 'execution-oversized' + ) + const storedBytes = Buffer.byteLength(JSON.stringify(compacted), 'utf8') + + expect(storedBytes).toBeLessThanOrEqual(500 * 1024) + expect(compacted.executionDataTruncated).toBe(true) + expect(compacted.executionState).toBeUndefined() + expect(compacted.executionStateSummary).toEqual({ + executedBlockCount: 1, + blockLogCount: 1, + completedLoopCount: 0, + activeExecutionPathLength: 0, + pendingQueueLength: 0, + }) + expect(compacted.traceSpans?.[0]?.children?.[0]?.input).toEqual({ + _truncated: true, + reason: 'execution_data_size_limit', + originalBytes: expect.any(Number), + summary: 'object with 2 keys', + }) + }) }) describe('file extraction', () => { diff --git a/apps/sim/lib/logs/execution/logger.ts b/apps/sim/lib/logs/execution/logger.ts index 07b7af219b..3dd29cea1f 100644 --- a/apps/sim/lib/logs/execution/logger.ts +++ b/apps/sim/lib/logs/execution/logger.ts @@ -7,6 +7,7 @@ import { workflowExecutionLogs, } from '@sim/db/schema' import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { eq, sql } from 'drizzle-orm' import { BASE_EXECUTION_CHARGE } from '@/lib/billing/constants' @@ -48,6 +49,225 @@ const TRIGGER_COUNTER_MAP: Record = { } as const const logger = createLogger('ExecutionLogger') +const MAX_EXECUTION_DATA_BYTES = 500 * 1024 +const MAX_TRACE_IO_BYTES = 8 * 1024 +const MAX_WORKFLOW_VALUE_BYTES = 64 * 1024 +const EXECUTION_LOG_STATEMENT_TIMEOUT_MS = 30_000 +const EXECUTION_LOG_LOCK_TIMEOUT_MS = 3_000 +const EXECUTION_LOG_IDLE_TIMEOUT_MS = 5_000 + +type ExecutionData = WorkflowExecutionLog['executionData'] + +function getJsonByteSize( + value: unknown, + maxBytes = MAX_EXECUTION_DATA_BYTES + 1 +): number | undefined { + const seen = new WeakSet() + let bytes = 0 + + const add = (amount: number) => { + bytes += amount + if (bytes > maxBytes) { + throw new Error('json_size_limit_reached') + } + } + + const visit = (item: unknown): void => { + if (item === undefined || typeof item === 'function' || typeof item === 'symbol') { + add(4) + return + } + if (item === null) { + add(4) + return + } + if (typeof item === 'string') { + add(Buffer.byteLength(JSON.stringify(item), 'utf8')) + return + } + if (typeof item === 'bigint') { + add(Buffer.byteLength(JSON.stringify(item.toString()), 'utf8')) + return + } + if (typeof item === 'number' || typeof item === 'boolean') { + add(Buffer.byteLength(JSON.stringify(item) ?? 'null', 'utf8')) + return + } + if (typeof item !== 'object') { + add(4) + return + } + if (seen.has(item)) { + return + } + seen.add(item) + + if (Array.isArray(item)) { + add(2) + item.forEach((entry, index) => { + if (index > 0) add(1) + visit(entry) + }) + return + } + + const entries = Object.entries(item) + add(2) + entries.forEach(([key, entry], index) => { + if (entry === undefined || typeof entry === 'function' || typeof entry === 'symbol') return + if (index > 0) add(1) + add(Buffer.byteLength(JSON.stringify(key), 'utf8') + 1) + visit(entry) + }) + } + + try { + visit(value) + return bytes + } catch (error) { + if (getErrorMessage(error) === 'json_size_limit_reached') { + return maxBytes + 1 + } + return undefined + } +} + +function describeValue(value: unknown): string { + if (value === null) return 'null' + if (value === undefined) return 'undefined' + if (Array.isArray(value)) return `array with ${value.length} items` + if (typeof value === 'string') return `string with ${value.length} characters` + if (typeof value === 'object') return `object with ${Object.keys(value).length} keys` + return typeof value +} + +function summarizeValueForExecutionData(value: unknown, maxBytes: number): unknown { + const size = getJsonByteSize(value, maxBytes) + if (size === undefined || size <= maxBytes) { + return value + } + + return { + _truncated: true, + reason: 'execution_data_size_limit', + originalBytes: size, + summary: describeValue(value), + } +} + +function summarizeTextForExecutionData(value: string | undefined): string | undefined { + if (!value) return value + const size = getJsonByteSize(value, MAX_TRACE_IO_BYTES) + if (size === undefined || size <= MAX_TRACE_IO_BYTES) { + return value + } + return `[Truncated ${size} byte text value due to execution log size limit]` +} + +function summarizeTraceSpansForExecutionData(traceSpans?: TraceSpan[]): TraceSpan[] | undefined { + if (!traceSpans) { + return traceSpans + } + + return traceSpans.map((span) => { + const { input, output, children, thinking, modelToolCalls, ...rest } = span + const summarized: TraceSpan = { ...rest } + + if (input !== undefined) { + summarized.input = summarizeValueForExecutionData(input, MAX_TRACE_IO_BYTES) as Record< + string, + unknown + > + } + if (output !== undefined) { + summarized.output = summarizeValueForExecutionData(output, MAX_TRACE_IO_BYTES) as Record< + string, + unknown + > + } + if (children?.length) { + summarized.children = summarizeTraceSpansForExecutionData(children) + } + if (thinking !== undefined) { + summarized.thinking = summarizeTextForExecutionData(thinking) + } + if ( + modelToolCalls !== undefined && + (getJsonByteSize(modelToolCalls, MAX_TRACE_IO_BYTES) ?? 0) <= MAX_TRACE_IO_BYTES + ) { + summarized.modelToolCalls = modelToolCalls + } + + return summarized + }) +} + +function summarizeTraceSpansWithoutIo(traceSpans?: TraceSpan[]): TraceSpan[] | undefined { + if (!traceSpans) { + return traceSpans + } + + return traceSpans.map((span) => { + const { + input: _input, + output: _output, + children, + thinking: _thinking, + modelToolCalls: _modelToolCalls, + ...rest + } = span + return { + ...rest, + ...(children?.length ? { children: summarizeTraceSpansWithoutIo(children) } : {}), + } + }) +} + +function summarizeExecutionState(executionState?: SerializableExecutionState) { + if (!executionState) { + return undefined + } + + return { + executedBlockCount: executionState.executedBlocks.length, + blockLogCount: executionState.blockLogs.length, + completedLoopCount: executionState.completedLoops.length, + activeExecutionPathLength: executionState.activeExecutionPath.length, + pendingQueueLength: executionState.pendingQueue?.length ?? 0, + } +} + +function recordStoredByteSize(executionData: ExecutionData): { + executionData: ExecutionData + storedBytes?: number +} { + const firstBytes = getJsonByteSize(executionData) + if (firstBytes === undefined) { + return { executionData } + } + + const withFirstSize = { ...executionData, executionDataStoredBytes: firstBytes } + const secondBytes = getJsonByteSize(withFirstSize) + if (secondBytes === undefined || secondBytes === firstBytes) { + return { executionData: withFirstSize, storedBytes: secondBytes ?? firstBytes } + } + + const withSecondSize = { ...executionData, executionDataStoredBytes: secondBytes } + return { + executionData: withSecondSize, + storedBytes: getJsonByteSize(withSecondSize) ?? secondBytes, + } +} + +async function setExecutionLogWriteTimeouts(trx: Pick): Promise { + await trx.execute( + sql.raw(`SET LOCAL statement_timeout = '${EXECUTION_LOG_STATEMENT_TIMEOUT_MS}ms'`) + ) + await trx.execute(sql.raw(`SET LOCAL lock_timeout = '${EXECUTION_LOG_LOCK_TIMEOUT_MS}ms'`)) + await trx.execute( + sql.raw(`SET LOCAL idle_in_transaction_session_timeout = '${EXECUTION_LOG_IDLE_TIMEOUT_MS}ms'`) + ) +} function countTraceSpans(traceSpans?: TraceSpan[]): number { if (!Array.isArray(traceSpans) || traceSpans.length === 0) { @@ -58,6 +278,133 @@ function countTraceSpans(traceSpans?: TraceSpan[]): number { } export class ExecutionLogger implements IExecutionLoggerService { + private compactExecutionDataForStorage( + executionData: ExecutionData, + executionId: string + ): ExecutionData { + const originalBytes = getJsonByteSize(executionData) + if (originalBytes === undefined || originalBytes <= MAX_EXECUTION_DATA_BYTES) { + return executionData + } + + const { executionState: _executionState, ...executionDataWithoutState } = executionData + const summarized: ExecutionData = { + ...executionDataWithoutState, + traceSpans: summarizeTraceSpansForExecutionData(executionData.traceSpans), + finalOutput: summarizeValueForExecutionData( + executionData.finalOutput, + MAX_WORKFLOW_VALUE_BYTES + ) as BlockOutputData, + executionDataTruncated: true, + executionDataOriginalBytes: originalBytes, + executionDataMaxBytes: MAX_EXECUTION_DATA_BYTES, + executionDataTruncationReason: + 'Execution log exceeded the maximum stored payload size, so large inputs and outputs were summarized.', + } + + if (executionData.workflowInput !== undefined) { + summarized.workflowInput = summarizeValueForExecutionData( + executionData.workflowInput, + MAX_WORKFLOW_VALUE_BYTES + ) + } + + if (executionData.executionState) { + summarized.executionStateSummary = summarizeExecutionState(executionData.executionState) + } + + const summarizedWithSize = recordStoredByteSize(summarized) + if ( + summarizedWithSize.storedBytes !== undefined && + summarizedWithSize.storedBytes <= MAX_EXECUTION_DATA_BYTES + ) { + logger.warn('Summarized oversized workflow execution data before storing log', { + executionId, + originalBytes, + storedBytes: summarizedWithSize.storedBytes, + maxBytes: MAX_EXECUTION_DATA_BYTES, + }) + return summarizedWithSize.executionData + } + + const minimal: ExecutionData = { + ...(executionData.environment ? { environment: executionData.environment } : {}), + ...(executionData.trigger ? { trigger: executionData.trigger } : {}), + ...(executionData.correlation ? { correlation: executionData.correlation } : {}), + ...(executionData.error ? { error: executionData.error } : {}), + ...(executionData.lastStartedBlock + ? { lastStartedBlock: executionData.lastStartedBlock } + : {}), + ...(executionData.lastCompletedBlock + ? { lastCompletedBlock: executionData.lastCompletedBlock } + : {}), + ...(executionData.completionFailure + ? { completionFailure: executionData.completionFailure } + : {}), + ...(executionData.finalizationPath + ? { finalizationPath: executionData.finalizationPath } + : {}), + hasTraceSpans: executionData.hasTraceSpans, + traceSpanCount: executionData.traceSpanCount, + traceSpans: summarizeTraceSpansWithoutIo(executionData.traceSpans), + finalOutput: summarizeValueForExecutionData(executionData.finalOutput, MAX_TRACE_IO_BYTES) as + | BlockOutputData + | undefined, + tokens: executionData.tokens, + models: executionData.models, + executionStateSummary: summarizeExecutionState(executionData.executionState), + executionDataTruncated: true, + executionDataOriginalBytes: originalBytes, + executionDataMaxBytes: MAX_EXECUTION_DATA_BYTES, + executionDataTruncationReason: + 'Execution log exceeded the maximum stored payload size after summarization, so trace payload details were omitted.', + } + + const minimalWithSize = recordStoredByteSize(minimal) + + if ( + minimalWithSize.storedBytes !== undefined && + minimalWithSize.storedBytes > MAX_EXECUTION_DATA_BYTES + ) { + const metadataOnly: ExecutionData = { + hasTraceSpans: executionData.hasTraceSpans, + traceSpanCount: executionData.traceSpanCount, + tokens: executionData.tokens, + models: executionData.models, + executionDataTruncated: true, + executionDataOriginalBytes: originalBytes, + executionDataMaxBytes: MAX_EXECUTION_DATA_BYTES, + executionDataTruncationReason: + 'Execution log exceeded the maximum stored payload size after minimal summarization, so only execution metadata was stored.', + } + + const metadataOnlyWithSize = recordStoredByteSize(metadataOnly) + logger.warn( + 'Stored metadata-only workflow execution data after oversized log summarization', + { + executionId, + originalBytes, + storedBytes: metadataOnlyWithSize.storedBytes, + minimalBytes: minimalWithSize.storedBytes, + summarizedBytes: summarizedWithSize.storedBytes, + maxBytes: MAX_EXECUTION_DATA_BYTES, + } + ) + + return metadataOnlyWithSize.executionData + } + + logger.warn('Stored minimal workflow execution data after oversized log summarization', { + executionId, + originalBytes, + storedBytes: minimalWithSize.storedBytes, + summarizedBytes: summarizedWithSize.storedBytes, + maxBytes: MAX_EXECUTION_DATA_BYTES, + }) + + return minimalWithSize.executionData + } + private buildCompletedExecutionData(params: { existingExecutionData?: WorkflowExecutionLog['executionData'] traceSpans?: TraceSpan[] @@ -324,9 +671,6 @@ export class ExecutionLogger implements IExecutionLoggerService { const level = levelOverride ?? (hasErrors ? 'error' : 'info') const status = statusOverride ?? (hasErrors ? 'failed' : 'completed') - // Extract files from trace spans, final output, and workflow input - const executionFiles = this.extractFilesFromExecution(traceSpans, finalOutput, workflowInput) - // For resume executions, rebuild trace spans from the aggregated logs const mergedTraceSpans = isResume ? traceSpans && traceSpans.length > 0 @@ -334,11 +678,6 @@ export class ExecutionLogger implements IExecutionLoggerService { : existingExecutionData?.traceSpans || [] : traceSpans - const filteredTraceSpans = filterForDisplay(mergedTraceSpans) - const filteredFinalOutput = filterForDisplay(finalOutput) - const redactedTraceSpans = redactApiKeys(filteredTraceSpans) - const redactedFinalOutput = redactApiKeys(filteredFinalOutput) - const executionCost = { total: costSummary.totalCost, input: costSummary.totalInputCost, @@ -351,6 +690,37 @@ export class ExecutionLogger implements IExecutionLoggerService { models: costSummary.models, } + const boundedExecutionData = this.compactExecutionDataForStorage( + this.buildCompletedExecutionData({ + existingExecutionData, + traceSpans: mergedTraceSpans, + finalOutput, + finalizationPath, + completionFailure, + executionCost, + executionState, + workflowInput, + }), + executionId + ) + + const executionFiles = this.extractFilesFromExecution( + boundedExecutionData.traceSpans, + boundedExecutionData.finalOutput, + boundedExecutionData.workflowInput + ) + + const filteredTraceSpans = filterForDisplay(boundedExecutionData.traceSpans) + const filteredFinalOutput = filterForDisplay(boundedExecutionData.finalOutput) + const filteredWorkflowInput = + boundedExecutionData.workflowInput !== undefined + ? filterForDisplay(boundedExecutionData.workflowInput) + : undefined + const redactedTraceSpans = redactApiKeys(filteredTraceSpans) + const redactedFinalOutput = redactApiKeys(filteredFinalOutput) + const redactedWorkflowInput = + filteredWorkflowInput !== undefined ? redactApiKeys(filteredWorkflowInput) : undefined + const rawDurationMs = isResume && existingLog?.startedAt ? new Date(endedAt).getTime() - new Date(existingLog.startedAt).getTime() @@ -360,30 +730,33 @@ export class ExecutionLogger implements IExecutionLoggerService { ? Math.max(0, Math.round(rawDurationMs)) : 0 - const completedExecutionData = this.buildCompletedExecutionData({ - existingExecutionData, - traceSpans: redactedTraceSpans, - finalOutput: redactedFinalOutput, - finalizationPath, - completionFailure, - executionCost, - executionState, - workflowInput, - }) + const completedExecutionData = this.compactExecutionDataForStorage( + { + ...boundedExecutionData, + traceSpans: redactedTraceSpans, + finalOutput: redactedFinalOutput, + ...(redactedWorkflowInput !== undefined ? { workflowInput: redactedWorkflowInput } : {}), + }, + executionId + ) - const [updatedLog] = await db - .update(workflowExecutionLogs) - .set({ - level, - status, - endedAt: new Date(endedAt), - totalDurationMs: totalDuration, - files: executionFiles.length > 0 ? executionFiles : null, - executionData: completedExecutionData, - cost: executionCost, - }) - .where(eq(workflowExecutionLogs.executionId, executionId)) - .returning() + const [updatedLog] = await db.transaction(async (tx) => { + await setExecutionLogWriteTimeouts(tx) + + return tx + .update(workflowExecutionLogs) + .set({ + level, + status, + endedAt: new Date(endedAt), + totalDurationMs: totalDuration, + files: executionFiles.length > 0 ? executionFiles : null, + executionData: completedExecutionData, + cost: executionCost, + }) + .where(eq(workflowExecutionLogs.executionId, executionId)) + .returning() + }) if (!updatedLog) { throw new Error(`Workflow log not found for execution ${executionId}`) @@ -717,10 +1090,13 @@ export class ExecutionLogger implements IExecutionLoggerService { ): any[] { const files: any[] = [] const seenFileIds = new Set() + const seenObjects = new WeakSet() // Helper function to extract files from any object const extractFilesFromObject = (obj: any, source: string) => { if (!obj || typeof obj !== 'object') return + if (seenObjects.has(obj)) return + seenObjects.add(obj) // Check if this object has files property if (Array.isArray(obj.files)) { diff --git a/apps/sim/lib/logs/types.ts b/apps/sim/lib/logs/types.ts index 4064b302e3..f4021439a3 100644 --- a/apps/sim/lib/logs/types.ts +++ b/apps/sim/lib/logs/types.ts @@ -152,6 +152,18 @@ export interface WorkflowExecutionLog { } > executionState?: SerializableExecutionState + executionStateSummary?: { + executedBlockCount: number + blockLogCount: number + completedLoopCount: number + activeExecutionPathLength: number + pendingQueueLength: number + } + executionDataTruncated?: boolean + executionDataOriginalBytes?: number + executionDataStoredBytes?: number + executionDataMaxBytes?: number + executionDataTruncationReason?: string finalOutput?: any workflowInput?: unknown errorDetails?: { diff --git a/apps/sim/lib/table/import.ts b/apps/sim/lib/table/import.ts index 117f4a6231..23566c145d 100644 --- a/apps/sim/lib/table/import.ts +++ b/apps/sim/lib/table/import.ts @@ -20,8 +20,8 @@ export const CSV_SCHEMA_SAMPLE_SIZE = 100 /** Maximum rows inserted per `batchInsertRows` call during import. */ export const CSV_MAX_BATCH_SIZE = 1000 -/** Maximum CSV/TSV file size accepted by import routes (50 MB). */ -export const CSV_MAX_FILE_SIZE_BYTES = 50 * 1024 * 1024 +/** Maximum CSV/TSV file size accepted by import routes (25 MB). */ +export const CSV_MAX_FILE_SIZE_BYTES = 25 * 1024 * 1024 /** * Error thrown when the user-supplied mapping or CSV does not line up with the diff --git a/apps/sim/lib/table/service.ts b/apps/sim/lib/table/service.ts index 7b09325195..62b517875d 100644 --- a/apps/sim/lib/table/service.ts +++ b/apps/sim/lib/table/service.ts @@ -383,20 +383,18 @@ export async function createTable( } const duplicateName = await trx - .select({ id: userTableDefinitions.id, archivedAt: userTableDefinitions.archivedAt }) + .select({ id: userTableDefinitions.id }) .from(userTableDefinitions) .where( and( eq(userTableDefinitions.workspaceId, data.workspaceId), - eq(userTableDefinitions.name, data.name) + eq(userTableDefinitions.name, data.name), + isNull(userTableDefinitions.archivedAt) ) ) .limit(1) if (duplicateName.length > 0) { - if (duplicateName[0].archivedAt) { - throw new TableConflictError(data.name) - } throw new TableConflictError(data.name) } diff --git a/apps/sim/lib/uploads/contexts/copilot/copilot-file-manager.ts b/apps/sim/lib/uploads/contexts/copilot/copilot-file-manager.ts index 2d44aa8c66..6c5145fa1b 100644 --- a/apps/sim/lib/uploads/contexts/copilot/copilot-file-manager.ts +++ b/apps/sim/lib/uploads/contexts/copilot/copilot-file-manager.ts @@ -1,9 +1,11 @@ import { createLogger } from '@sim/logger' +import { getBaseUrl } from '@/lib/core/utils/urls' import { deleteFile, downloadFile, generatePresignedDownloadUrl, generatePresignedUploadUrl, + uploadFile, } from '@/lib/uploads/core/storage-service' import type { PresignedUrlResponse } from '@/lib/uploads/shared/types' import { isImageFileType } from '@/lib/uploads/utils/file-utils' @@ -52,6 +54,17 @@ export interface GenerateCopilotUploadUrlOptions { expirationSeconds?: number } +export interface CopilotStoredFile { + id: string + key: string + context: 'copilot' + name: string + url: string + size: number + type: string + mimeType: string +} + /** * Generate a presigned URL for copilot file upload * @@ -94,6 +107,45 @@ export async function generateCopilotUploadUrl( return presignedUrlResponse } +export async function uploadCopilotFile(options: { + buffer: Buffer + fileName: string + contentType: string + userId: string +}): Promise { + const fileInfo = await uploadFile({ + file: options.buffer, + fileName: options.fileName, + contentType: options.contentType, + context: 'copilot', + metadata: { + userId: options.userId, + originalName: options.fileName, + uploadedAt: new Date().toISOString(), + purpose: 'copilot-tool-output', + }, + }) + + const url = `${getBaseUrl()}${fileInfo.path}` + + logger.info(`Stored copilot tool output: ${options.fileName}`, { + key: fileInfo.key, + size: fileInfo.size, + userId: options.userId, + }) + + return { + id: fileInfo.key, + key: fileInfo.key, + context: 'copilot', + name: fileInfo.name, + url, + size: fileInfo.size, + type: fileInfo.type, + mimeType: fileInfo.type, + } +} + /** * Download a copilot file from storage * diff --git a/apps/sim/lib/uploads/contexts/copilot/index.ts b/apps/sim/lib/uploads/contexts/copilot/index.ts index dab6cad208..d4b1c93a3e 100644 --- a/apps/sim/lib/uploads/contexts/copilot/index.ts +++ b/apps/sim/lib/uploads/contexts/copilot/index.ts @@ -1,4 +1,6 @@ +export type { CopilotStoredFile } from './copilot-file-manager' export { downloadCopilotFile, generateCopilotUploadUrl, + uploadCopilotFile, } from './copilot-file-manager' diff --git a/apps/sim/lib/uploads/contexts/execution/execution-file-manager.ts b/apps/sim/lib/uploads/contexts/execution/execution-file-manager.ts index 1fb9c43509..304da53a9c 100644 --- a/apps/sim/lib/uploads/contexts/execution/execution-file-manager.ts +++ b/apps/sim/lib/uploads/contexts/execution/execution-file-manager.ts @@ -1,13 +1,17 @@ import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' +import { isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits' import { isUserFileWithMetadata } from '@/lib/core/utils/user-file' -import { StorageService } from '@/lib/uploads' import type { ExecutionContext } from '@/lib/uploads/contexts/execution/utils' import { generateExecutionFileKey, generateFileId } from '@/lib/uploads/contexts/execution/utils' import type { UserFile } from '@/executor/types' const logger = createLogger('ExecutionFileStorage') +async function getStorageService() { + return import('@/lib/uploads/core/storage-service') +} + function isSerializedBuffer(value: unknown): value is { type: string; data: number[] } { return ( !!value && @@ -91,6 +95,7 @@ export async function uploadExecutionFile( } try { + const StorageService = await getStorageService() const fileInfo = await StorageService.uploadFile({ file: fileBuffer, fileName: storageKey, @@ -130,13 +135,18 @@ export async function uploadExecutionFile( /** * Download a file from execution-scoped storage */ -export async function downloadExecutionFile(userFile: UserFile): Promise { +export async function downloadExecutionFile( + userFile: UserFile, + options: { maxBytes?: number } = {} +): Promise { logger.info(`Downloading execution file: ${userFile.name}`) try { + const StorageService = await getStorageService() const fileBuffer = await StorageService.downloadFile({ key: userFile.key, context: 'execution', + ...(options.maxBytes === undefined ? {} : { maxBytes: options.maxBytes }), }) logger.info( @@ -144,6 +154,9 @@ export async function downloadExecutionFile(userFile: UserFile): Promise ) return fileBuffer } catch (error) { + if (isPayloadSizeLimitError(error)) { + throw error + } logger.error(`Failed to download execution file ${userFile.name}:`, error) throw new Error(`Failed to download file: ${getErrorMessage(error, 'Unknown error')}`) } diff --git a/apps/sim/lib/uploads/core/storage-service.ts b/apps/sim/lib/uploads/core/storage-service.ts index 7a5ba092f2..f730d49bea 100644 --- a/apps/sim/lib/uploads/core/storage-service.ts +++ b/apps/sim/lib/uploads/core/storage-service.ts @@ -1,6 +1,7 @@ import { randomBytes } from 'crypto' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' +import { assertKnownSizeWithinLimit } from '@/lib/core/utils/stream-limits' import { getStorageConfig, USE_BLOB_STORAGE, USE_S3_STORAGE } from '@/lib/uploads/config' import type { BlobConfig } from '@/lib/uploads/providers/blob/types' import type { S3Config } from '@/lib/uploads/providers/s3/types' @@ -184,29 +185,40 @@ export async function uploadFile(options: UploadFileOptions): Promise * Download a file from the configured storage provider */ export async function downloadFile(options: DownloadFileOptions): Promise { - const { key, context } = options + const { key, context, maxBytes } = options if (context) { const config = getStorageConfig(context) if (USE_BLOB_STORAGE) { const { downloadFromBlob } = await import('@/lib/uploads/providers/blob/client') - return downloadFromBlob(key, createBlobConfig(config)) + const blobConfig = createBlobConfig(config) + return maxBytes === undefined + ? downloadFromBlob(key, blobConfig) + : downloadFromBlob(key, blobConfig, maxBytes) } if (USE_S3_STORAGE) { const { downloadFromS3 } = await import('@/lib/uploads/providers/s3/client') - return downloadFromS3(key, createS3Config(config)) + const s3Config = createS3Config(config) + return maxBytes === undefined + ? downloadFromS3(key, s3Config) + : downloadFromS3(key, s3Config, maxBytes) } } - const { readFile } = await import('fs/promises') + const { readFile, stat } = await import('fs/promises') const { join } = await import('path') const { UPLOAD_DIR_SERVER } = await import('./setup.server') const safeKey = sanitizeFileKey(key) const filePath = join(UPLOAD_DIR_SERVER, safeKey) + if (maxBytes !== undefined) { + const fileStats = await stat(filePath) + assertKnownSizeWithinLimit(fileStats.size, maxBytes, 'storage download') + } + return readFile(filePath) } diff --git a/apps/sim/lib/uploads/providers/blob/client.test.ts b/apps/sim/lib/uploads/providers/blob/client.test.ts index 484dce6f2a..7e15a7095c 100644 --- a/apps/sim/lib/uploads/providers/blob/client.test.ts +++ b/apps/sim/lib/uploads/providers/blob/client.test.ts @@ -144,6 +144,7 @@ describe('Azure Blob Storage Client', () => { callback() } }), + off: vi.fn(() => mockReadableStream), } mockDownload.mockResolvedValueOnce({ @@ -156,6 +157,24 @@ describe('Azure Blob Storage Client', () => { expect(mockDownload).toHaveBeenCalled() expect(result).toEqual(testContent) }) + + it('should destroy the opened stream when content length exceeds the limit', async () => { + const mockDestroy = vi.fn() + const mockReadableStream = { + destroy: mockDestroy, + on: vi.fn(() => mockReadableStream), + } + + mockDownload.mockResolvedValueOnce({ + readableStreamBody: mockReadableStream, + contentLength: 1024, + }) + + await expect(downloadFromBlob('large-file-key', undefined, 10)).rejects.toThrow( + 'storage download exceeds maximum size' + ) + expect(mockDestroy).toHaveBeenCalledWith(expect.any(Error)) + }) }) describe('deleteFromBlob', () => { diff --git a/apps/sim/lib/uploads/providers/blob/client.ts b/apps/sim/lib/uploads/providers/blob/client.ts index ee1c050566..e329799eed 100644 --- a/apps/sim/lib/uploads/providers/blob/client.ts +++ b/apps/sim/lib/uploads/providers/blob/client.ts @@ -1,6 +1,10 @@ import type { BlobServiceClient as BlobServiceClientType } from '@azure/storage-blob' import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' +import { + assertKnownSizeWithinLimit, + readNodeStreamToBufferWithLimit, +} from '@/lib/core/utils/stream-limits' import { BLOB_CONFIG } from '@/lib/uploads/config' import type { AzureMultipartPart, @@ -267,7 +271,17 @@ export async function downloadFromBlob(key: string): Promise */ export async function downloadFromBlob(key: string, customConfig: BlobConfig): Promise -export async function downloadFromBlob(key: string, customConfig?: BlobConfig): Promise { +export async function downloadFromBlob( + key: string, + customConfig: BlobConfig, + maxBytes: number +): Promise + +export async function downloadFromBlob( + key: string, + customConfig?: BlobConfig, + maxBytes?: number +): Promise { const { BlobServiceClient, StorageSharedKeyCredential } = await import('@azure/storage-blob') let blobServiceClient: BlobServiceClientType let containerName: string @@ -297,10 +311,32 @@ export async function downloadFromBlob(key: string, customConfig?: BlobConfig): const blockBlobClient = containerClient.getBlockBlobClient(key) const downloadBlockBlobResponse = await blockBlobClient.download() + if (maxBytes !== undefined && downloadBlockBlobResponse.contentLength !== undefined) { + try { + assertKnownSizeWithinLimit( + downloadBlockBlobResponse.contentLength, + maxBytes, + 'storage download' + ) + } catch (error) { + const stream = downloadBlockBlobResponse.readableStreamBody as + | { destroy?: (error?: Error) => void } + | undefined + stream?.destroy?.(error instanceof Error ? error : undefined) + throw error + } + } + if (!downloadBlockBlobResponse.readableStreamBody) { throw new Error('Failed to get readable stream from blob download') } - const downloaded = await streamToBuffer(downloadBlockBlobResponse.readableStreamBody) + const downloaded = await readNodeStreamToBufferWithLimit( + downloadBlockBlobResponse.readableStreamBody, + { + maxBytes: maxBytes ?? Number.MAX_SAFE_INTEGER, + label: 'storage download', + } + ) return downloaded } @@ -402,22 +438,6 @@ export async function deleteFromBlob(key: string, customConfig?: BlobConfig): Pr await blockBlobClient.delete() } -/** - * Helper function to convert a readable stream to a Buffer - */ -async function streamToBuffer(readableStream: NodeJS.ReadableStream): Promise { - return new Promise((resolve, reject) => { - const chunks: Buffer[] = [] - readableStream.on('data', (data) => { - chunks.push(data instanceof Buffer ? data : Buffer.from(data)) - }) - readableStream.on('end', () => { - resolve(Buffer.concat(chunks)) - }) - readableStream.on('error', reject) - }) -} - /** * Derive the deterministic Azure block id for a given part number. * Block ids must be base64-encoded and equal length within an upload; using a diff --git a/apps/sim/lib/uploads/providers/s3/client.test.ts b/apps/sim/lib/uploads/providers/s3/client.test.ts index d30ab5b87e..ff780b5586 100644 --- a/apps/sim/lib/uploads/providers/s3/client.test.ts +++ b/apps/sim/lib/uploads/providers/s3/client.test.ts @@ -53,9 +53,7 @@ vi.mock('@/lib/core/config/env', () => ({ isTruthy: (value: string | boolean | number | undefined) => typeof value === 'string' ? value.toLowerCase() === 'true' || value === '1' : Boolean(value), isFalsy: (value: string | boolean | number | undefined) => - typeof value === 'string' - ? value.toLowerCase() === 'false' || value === '0' - : value === false, + typeof value === 'string' ? value.toLowerCase() === 'false' || value === '0' : value === false, })) vi.mock('@/lib/uploads/setup', () => ({ @@ -228,6 +226,7 @@ describe('S3 Client', () => { } return mockStream }), + off: vi.fn(() => mockStream), } mockSend.mockResolvedValueOnce({ @@ -257,6 +256,7 @@ describe('S3 Client', () => { } return mockStream }), + off: vi.fn(() => mockStream), } mockSend.mockResolvedValueOnce({ @@ -269,6 +269,25 @@ describe('S3 Client', () => { await expect(downloadFromS3(key)).rejects.toThrow('Stream error') }) + it('should destroy the opened stream when content length exceeds the limit', async () => { + const mockDestroy = vi.fn() + const mockStream = { + destroy: mockDestroy, + on: vi.fn(() => mockStream), + } + + mockSend.mockResolvedValueOnce({ + Body: mockStream, + ContentLength: 1024, + $metadata: { httpStatusCode: 200 }, + }) + + await expect( + downloadFromS3('large-file.txt', { bucket: 'test-bucket', region: 'test-region' }, 10) + ).rejects.toThrow('storage download exceeds maximum size') + expect(mockDestroy).toHaveBeenCalledWith(expect.any(Error)) + }) + it('should handle S3 client errors', async () => { const error = new Error('Download failed') mockSend.mockRejectedValueOnce(error) diff --git a/apps/sim/lib/uploads/providers/s3/client.ts b/apps/sim/lib/uploads/providers/s3/client.ts index f31bd21718..fe939cb506 100644 --- a/apps/sim/lib/uploads/providers/s3/client.ts +++ b/apps/sim/lib/uploads/providers/s3/client.ts @@ -14,6 +14,10 @@ import { getSignedUrl } from '@aws-sdk/s3-request-presigner' import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { env } from '@/lib/core/config/env' +import { + assertKnownSizeWithinLimit, + readNodeStreamToBufferWithLimit, +} from '@/lib/core/utils/stream-limits' import { S3_CONFIG, S3_KB_CONFIG } from '@/lib/uploads/config' import type { S3Config, @@ -181,7 +185,17 @@ export async function downloadFromS3(key: string): Promise */ export async function downloadFromS3(key: string, customConfig: S3Config): Promise -export async function downloadFromS3(key: string, customConfig?: S3Config): Promise { +export async function downloadFromS3( + key: string, + customConfig: S3Config, + maxBytes: number +): Promise + +export async function downloadFromS3( + key: string, + customConfig?: S3Config, + maxBytes?: number +): Promise { const config = customConfig || { bucket: S3_CONFIG.bucket, region: S3_CONFIG.region } const command = new GetObjectCommand({ @@ -190,13 +204,20 @@ export async function downloadFromS3(key: string, customConfig?: S3Config): Prom }) const response = await getS3Client().send(command) - const stream = response.Body as any + if (maxBytes !== undefined && response.ContentLength !== undefined) { + try { + assertKnownSizeWithinLimit(response.ContentLength, maxBytes, 'storage download') + } catch (error) { + const body = response.Body as { destroy?: (error?: Error) => void } | undefined + body?.destroy?.(error instanceof Error ? error : undefined) + throw error + } + } - return new Promise((resolve, reject) => { - const chunks: Buffer[] = [] - stream.on('data', (chunk: Buffer) => chunks.push(chunk)) - stream.on('end', () => resolve(Buffer.concat(chunks))) - stream.on('error', reject) + const stream = response.Body as NodeJS.ReadableStream + return readNodeStreamToBufferWithLimit(stream, { + maxBytes: maxBytes ?? Number.MAX_SAFE_INTEGER, + label: 'storage download', }) } diff --git a/apps/sim/lib/uploads/shared/types.ts b/apps/sim/lib/uploads/shared/types.ts index bba56ad348..827c08b0f7 100644 --- a/apps/sim/lib/uploads/shared/types.ts +++ b/apps/sim/lib/uploads/shared/types.ts @@ -70,6 +70,7 @@ export interface UploadFileOptions { export interface DownloadFileOptions { key: string context?: StorageContext + maxBytes?: number } export interface DeleteFileOptions { diff --git a/apps/sim/lib/uploads/utils/file-utils.server.ts b/apps/sim/lib/uploads/utils/file-utils.server.ts index 5d62fb4be7..f0fb7a606a 100644 --- a/apps/sim/lib/uploads/utils/file-utils.server.ts +++ b/apps/sim/lib/uploads/utils/file-utils.server.ts @@ -7,6 +7,11 @@ import { secureFetchWithPinnedIP, validateUrlWithDNS, } from '@/lib/core/security/input-validation.server' +import { + assertKnownSizeWithinLimit, + consumeOrCancelBody, + readResponseToBufferWithLimit, +} from '@/lib/core/utils/stream-limits' import type { StorageContext } from '@/lib/uploads' import { StorageService } from '@/lib/uploads' import { isExecutionFile } from '@/lib/uploads/contexts/execution/utils' @@ -139,14 +144,15 @@ export async function resolveFileInputToUrl( */ export async function downloadFileFromUrl( fileUrl: string, - timeoutMs = getMaxExecutionTimeout() + timeoutMs = getMaxExecutionTimeout(), + maxBytes?: number ): Promise { const { parseInternalFileUrl } = await import('./file-utils') if (isInternalFileUrl(fileUrl)) { const { key, context } = parseInternalFileUrl(fileUrl) const { downloadFile } = await import('@/lib/uploads/core/storage-service') - return downloadFile({ key, context }) + return downloadFile({ key, context, maxBytes }) } const urlValidation = await validateUrlWithDNS(fileUrl, 'fileUrl') @@ -156,13 +162,18 @@ export async function downloadFileFromUrl( const response = await secureFetchWithPinnedIP(fileUrl, urlValidation.resolvedIP!, { timeout: timeoutMs, + maxResponseBytes: maxBytes, }) if (!response.ok) { + await consumeOrCancelBody(response) throw new Error(`Failed to download file: ${response.statusText}`) } - return Buffer.from(await response.arrayBuffer()) + return readResponseToBufferWithLimit(response, { + maxBytes: maxBytes ?? Number.MAX_SAFE_INTEGER, + label: 'file download', + }) } export async function resolveInternalFileUrl( @@ -208,16 +219,20 @@ export async function resolveInternalFileUrl( export async function downloadFileFromStorage( userFile: UserFile, requestId: string, - logger: Logger + logger: Logger, + options: { maxBytes?: number } = {} ): Promise { let buffer: Buffer + if (options.maxBytes !== undefined && userFile.size > options.maxBytes) { + assertKnownSizeWithinLimit(userFile.size, options.maxBytes, 'storage file download') + } if (isExecutionFile(userFile)) { logger.info(`[${requestId}] Downloading from execution storage: ${userFile.key}`) const { downloadExecutionFile } = await import( '@/lib/uploads/contexts/execution/execution-file-manager' ) - buffer = await downloadExecutionFile(userFile) + buffer = await downloadExecutionFile(userFile, { maxBytes: options.maxBytes }) } else if (userFile.key) { const context = (userFile.context as StorageContext) || inferContextFromKey(userFile.key) logger.info( @@ -228,10 +243,15 @@ export async function downloadFileFromStorage( buffer = await downloadFile({ key: userFile.key, context, + maxBytes: options.maxBytes, }) } else { throw new Error('File has no key - cannot download') } + if (options.maxBytes !== undefined) { + assertKnownSizeWithinLimit(buffer.length, options.maxBytes, 'storage file download') + } + return buffer } diff --git a/apps/sim/tools/docusign/download_document.test.ts b/apps/sim/tools/docusign/download_document.test.ts new file mode 100644 index 0000000000..b4f315e536 --- /dev/null +++ b/apps/sim/tools/docusign/download_document.test.ts @@ -0,0 +1,59 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { docusignDownloadDocumentTool } from '@/tools/docusign/download_document' + +describe('DocuSign download document tool', () => { + it('forwards execution context to the internal route', () => { + const body = docusignDownloadDocumentTool.request.body?.({ + accessToken: 'token', + envelopeId: 'envelope-1', + documentId: 'combined', + _context: { + workspaceId: 'workspace-1', + workflowId: 'workflow-1', + executionId: 'execution-1', + }, + }) + + expect(body).toMatchObject({ + accessToken: 'token', + operation: 'download_document', + envelopeId: 'envelope-1', + documentId: 'combined', + workspaceId: 'workspace-1', + workflowId: 'workflow-1', + executionId: 'execution-1', + }) + }) + + it('returns file outputs from execution-context downloads', async () => { + const file = { + id: 'file-1', + name: 'signed.pdf', + size: 128, + type: 'application/pdf', + url: '/api/files/serve/execution/file-1', + key: 'execution/workflow/file-1', + context: 'execution', + } + const response = new Response( + JSON.stringify({ + file, + mimeType: 'application/pdf', + fileName: 'signed.pdf', + }), + { status: 200, headers: { 'content-type': 'application/json' } } + ) + + const result = await docusignDownloadDocumentTool.transformResponse?.(response) + + expect(result?.output).toEqual({ + file, + mimeType: 'application/pdf', + fileName: 'signed.pdf', + }) + expect(result?.output.base64Content).toBeUndefined() + }) +}) diff --git a/apps/sim/tools/docusign/download_document.ts b/apps/sim/tools/docusign/download_document.ts index f6325051fb..ced4cb6787 100644 --- a/apps/sim/tools/docusign/download_document.ts +++ b/apps/sim/tools/docusign/download_document.ts @@ -4,6 +4,19 @@ import type { } from '@/tools/docusign/types' import type { ToolConfig } from '@/tools/types' +function getExecutionContext(params: DocuSignDownloadDocumentParams): { + workspaceId?: string + workflowId?: string + executionId?: string +} { + const context = params._context + return { + workspaceId: typeof context?.workspaceId === 'string' ? context.workspaceId : undefined, + workflowId: typeof context?.workflowId === 'string' ? context.workflowId : undefined, + executionId: typeof context?.executionId === 'string' ? context.executionId : undefined, + } +} + export const docusignDownloadDocumentTool: ToolConfig< DocuSignDownloadDocumentParams, DocuSignDownloadDocumentResponse @@ -44,12 +57,16 @@ export const docusignDownloadDocumentTool: ToolConfig< url: '/api/tools/docusign', method: 'POST', headers: () => ({ 'Content-Type': 'application/json' }), - body: (params) => ({ - accessToken: params.accessToken, - operation: 'download_document', - envelopeId: params.envelopeId, - documentId: params.documentId, - }), + body: (params) => { + const context = getExecutionContext(params) + return { + accessToken: params.accessToken, + operation: 'download_document', + envelopeId: params.envelopeId, + documentId: params.documentId, + ...context, + } + }, }, transformResponse: async (response) => { @@ -60,7 +77,8 @@ export const docusignDownloadDocumentTool: ToolConfig< return { success: true, output: { - base64Content: data.base64Content ?? '', + ...(data.file ? { file: data.file } : {}), + ...(typeof data.base64Content === 'string' ? { base64Content: data.base64Content } : {}), mimeType: data.mimeType ?? 'application/pdf', fileName: data.fileName ?? 'document.pdf', }, @@ -68,7 +86,12 @@ export const docusignDownloadDocumentTool: ToolConfig< }, outputs: { - base64Content: { type: 'string', description: 'Base64-encoded document content' }, + file: { type: 'file', description: 'Stored downloaded document file', optional: true }, + base64Content: { + type: 'string', + description: 'Deprecated legacy inline content. New downloads return file.', + optional: true, + }, mimeType: { type: 'string', description: 'MIME type of the document' }, fileName: { type: 'string', description: 'Original file name' }, }, diff --git a/apps/sim/tools/docusign/types.ts b/apps/sim/tools/docusign/types.ts index 910687dc93..e7545f3a3f 100644 --- a/apps/sim/tools/docusign/types.ts +++ b/apps/sim/tools/docusign/types.ts @@ -1,3 +1,4 @@ +import type { UserFile } from '@/executor/types' import type { OutputProperty, ToolResponse } from '@/tools/types' /** Common envelope output properties */ @@ -136,6 +137,7 @@ export interface DocuSignDownloadDocumentParams { accessToken: string envelopeId: string documentId?: string + _context?: Record } export interface DocuSignListTemplatesParams { @@ -208,7 +210,8 @@ export interface DocuSignVoidEnvelopeResponse extends ToolResponse { export interface DocuSignDownloadDocumentResponse extends ToolResponse { output: { - base64Content: string + base64Content?: string + file?: UserFile mimeType: string fileName: string } diff --git a/apps/sim/tools/file/parser.test.ts b/apps/sim/tools/file/parser.test.ts new file mode 100644 index 0000000000..5300ed64da --- /dev/null +++ b/apps/sim/tools/file/parser.test.ts @@ -0,0 +1,117 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { fileFetchTool, fileParserTool, fileParserV3Tool } from '@/tools/file/parser' + +describe('fileParserTool', () => { + it('propagates parse route failures as tool failures', async () => { + const result = await fileParserTool.transformResponse?.( + Response.json({ + success: false, + error: 'File is too large to parse safely.', + filePath: 'https://example.com/big.pdf', + }) + ) + + expect(result).toMatchObject({ + success: false, + error: 'File is too large to parse safely.', + output: { + files: [], + combinedContent: '', + }, + }) + }) + + it('propagates parse route failures from V3 and file fetch tools', async () => { + const body = { + success: false, + error: 'File is too large to parse safely.', + filePath: 'https://example.com/big.pdf', + } + + await expect(fileParserV3Tool.transformResponse?.(Response.json(body))).resolves.toMatchObject({ + success: false, + error: 'File is too large to parse safely.', + output: { + files: [], + combinedContent: '', + }, + }) + await expect(fileFetchTool.transformResponse?.(Response.json(body))).resolves.toMatchObject({ + success: false, + error: 'File is too large to parse safely.', + output: { + files: [], + combinedContent: '', + }, + }) + }) + + it('omits failed entries from partial multi-file parse results', async () => { + const result = await fileParserTool.transformResponse?.( + Response.json({ + success: true, + results: [ + { + success: false, + error: 'First file failed', + filePath: 'bad.pdf', + }, + { + success: true, + output: { + content: 'ok', + fileType: 'text/plain', + size: 2, + name: 'ok.txt', + binary: false, + }, + }, + ], + }) + ) + + expect(result).toMatchObject({ + success: true, + output: { + files: [{ name: 'ok.txt', content: 'ok' }], + combinedContent: 'ok', + }, + }) + }) + + it('preserves partial multi-file parse successes from an oversized response', async () => { + const result = await fileParserTool.transformResponse?.( + Response.json( + { + success: false, + error: 'Parsed file output is too large to return safely.', + results: [ + { + success: true, + output: { + content: 'ok', + fileType: 'text/plain', + size: 2, + name: 'ok.txt', + binary: false, + }, + }, + ], + }, + { status: 413 } + ) + ) + + expect(result).toMatchObject({ + success: true, + error: 'Parsed file output is too large to return safely.', + output: { + files: [{ name: 'ok.txt', content: 'ok' }], + combinedContent: 'ok', + }, + }) + }) +}) diff --git a/apps/sim/tools/file/parser.ts b/apps/sim/tools/file/parser.ts index d98e08de65..569cbf1ada 100644 --- a/apps/sim/tools/file/parser.ts +++ b/apps/sim/tools/file/parser.ts @@ -93,15 +93,32 @@ const parseFileParserResponse = async (response: Response): Promise - normalizeFileParseResult(fileResult) + const failedResults = result.results.filter( + (fileResult) => isRecord(fileResult) && fileResult.success === false ) + if (failedResults.length === result.results.length) { + const firstError = failedResults.find( + (fileResult) => isRecord(fileResult) && typeof fileResult.error === 'string' + ) + return { + success: false, + output: { + files: [], + combinedContent: '', + }, + error: + isRecord(firstError) && typeof firstError.error === 'string' + ? firstError.error + : 'Failed to parse files', + } + } - // Collect UserFile objects from results - const processedFiles: UserFile[] = fileResults - .filter((file): file is FileParseResult & { file: UserFile } => Boolean(file.file)) - .map((file) => file.file) + // Extract individual file results + const fileResults: FileParseResult[] = result.results + .filter((fileResult) => !(isRecord(fileResult) && fileResult.success === false)) + .map((fileResult) => normalizeFileParseResult(fileResult)) + + const processedFiles = fileResults.flatMap((file) => (file.file ? [file.file] : [])) // Combine all file contents with clear dividers const combinedContent = fileResults @@ -118,10 +135,23 @@ const parseFileParserResponse = async (response: Response): Promise 0 && { processedFiles }), } + const error = typeof result.error === 'string' ? result.error : undefined return { success: true, output, + ...(error ? { error } : {}), + } + } + + if (isRecord(result) && result.success === false) { + return { + success: false, + output: { + files: [], + combinedContent: '', + }, + error: typeof result.error === 'string' ? result.error : 'Failed to parse file', } } @@ -305,6 +335,17 @@ export const fileParserV2Tool: ToolConfig = { const parseFileParserV3Response = async (response: Response): Promise => { const parsed = await parseFileParserResponse(response) + if (!parsed.success) { + return { + success: false, + output: { + files: [], + combinedContent: '', + }, + error: parsed.error, + } + } + const output = parsed.output as FileParserOutputData const files = Array.isArray(output.processedFiles) && output.processedFiles.length > 0 diff --git a/apps/sim/tools/google_slides/export_presentation.test.ts b/apps/sim/tools/google_slides/export_presentation.test.ts new file mode 100644 index 0000000000..247b7113b8 --- /dev/null +++ b/apps/sim/tools/google_slides/export_presentation.test.ts @@ -0,0 +1,223 @@ +/** + * @vitest-environment node + */ +import { + createMockRequest, + hybridAuthMockFns, + inputValidationMock, + inputValidationMockFns, +} from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockUploadCopilotFile, mockUploadExecutionFile } = vi.hoisted(() => ({ + mockUploadCopilotFile: vi.fn(), + mockUploadExecutionFile: vi.fn(), +})) + +vi.mock('@/lib/core/security/input-validation.server', () => inputValidationMock) +vi.mock('@/lib/uploads/contexts/copilot', () => ({ + uploadCopilotFile: mockUploadCopilotFile, +})) +vi.mock('@/lib/uploads/contexts/execution', () => ({ + uploadExecutionFile: mockUploadExecutionFile, +})) + +import { POST } from '@/app/api/tools/google_slides/export-presentation/route' +import type { ExportPresentationParams } from '@/tools/google_slides/export_presentation' +import { exportPresentationTool } from '@/tools/google_slides/export_presentation' + +describe('Google Slides export presentation tool', () => { + beforeEach(() => { + vi.clearAllMocks() + hybridAuthMockFns.mockCheckInternalAuth.mockResolvedValue({ + success: true, + userId: 'user-1', + authType: 'internal_jwt', + }) + inputValidationMockFns.mockValidateUrlWithDNS.mockResolvedValue({ + isValid: true, + resolvedIP: '93.184.216.34', + originalHostname: 'www.googleapis.com', + }) + mockUploadExecutionFile.mockResolvedValue({ + id: 'file-1', + name: 'presentation-1.pdf', + size: 7, + type: 'application/pdf', + url: '/api/files/serve/execution/file-1', + key: 'execution/workflow/file-1', + context: 'execution', + }) + mockUploadCopilotFile.mockResolvedValue({ + id: 'copilot-file-1', + name: 'presentation-1.pdf', + size: 4, + type: 'application/pdf', + mimeType: 'application/pdf', + url: '/api/files/serve/copilot/copilot-file-1', + key: 'copilot/copilot-file-1', + context: 'copilot', + }) + }) + + it('routes exports through the internal API with execution context', () => { + const params: ExportPresentationParams = { + accessToken: 'token', + presentationId: 'presentation-1', + exportFormat: 'PDF', + _context: { + workspaceId: 'workspace-1', + workflowId: 'workflow-1', + executionId: 'execution-1', + }, + } + + expect(exportPresentationTool.request.url).toBe('/api/tools/google_slides/export-presentation') + expect(exportPresentationTool.request.method).toBe('POST') + expect(exportPresentationTool.request.body?.(params)).toEqual({ + accessToken: 'token', + presentationId: 'presentation-1', + exportFormat: 'PDF', + workspaceId: 'workspace-1', + workflowId: 'workflow-1', + executionId: 'execution-1', + }) + }) + + it('rejects presentation IDs that would break export URL structure', async () => { + const response = await POST( + createMockRequest('POST', { + accessToken: 'token', + presentationId: 'abc?mimeType=evil', + exportFormat: 'PDF', + }) + ) + const result = (await response.json()) as { success: false; error: string } + + expect(response.status).toBe(400) + expect(result.error).toContain('invalid characters') + expect(inputValidationMockFns.mockSecureFetchWithPinnedIP).not.toHaveBeenCalled() + }) + + it('stores exports as execution file references and keeps small legacy base64 output', async () => { + inputValidationMockFns.mockSecureFetchWithPinnedIP.mockResolvedValueOnce( + new Response('content', { + status: 200, + headers: { 'content-type': 'application/pdf' }, + }) + ) + + const response = await POST( + createMockRequest('POST', { + accessToken: 'token', + presentationId: 'presentation-1', + exportFormat: 'PDF', + workspaceId: 'workspace-1', + workflowId: 'workflow-1', + executionId: 'execution-1', + }) + ) + const result = (await response.json()) as { + success: true + output: { + file: { key: string; context: string; mimeType?: string } + contentBase64?: string + } + } + + expect(response.status).toBe(200) + expect(inputValidationMockFns.mockSecureFetchWithPinnedIP).toHaveBeenCalledWith( + 'https://www.googleapis.com/drive/v3/files/presentation-1/export?mimeType=application%2Fpdf', + '93.184.216.34', + expect.objectContaining({ + headers: { Authorization: 'Bearer token' }, + maxResponseBytes: 10 * 1024 * 1024, + }) + ) + expect(mockUploadExecutionFile).toHaveBeenCalledWith( + { workspaceId: 'workspace-1', workflowId: 'workflow-1', executionId: 'execution-1' }, + Buffer.from('content'), + 'presentation-1.pdf', + 'application/pdf', + 'user-1' + ) + expect(result?.output.file).toMatchObject({ + key: 'execution/workflow/file-1', + context: 'execution', + mimeType: 'application/pdf', + }) + expect(result.output.contentBase64).toBe(Buffer.from('content').toString('base64')) + }) + + it('stores exports in copilot storage when execution context is unavailable', async () => { + const bytes = Uint8Array.from([0, 255, 1, 254]) + inputValidationMockFns.mockSecureFetchWithPinnedIP.mockResolvedValueOnce( + new Response(bytes, { + status: 200, + headers: { 'content-type': 'application/pdf' }, + }) + ) + + const response = await POST( + createMockRequest('POST', { + accessToken: 'token', + presentationId: 'presentation-1', + exportFormat: 'PDF', + }) + ) + const result = (await response.json()) as { + success: true + output: { + file: { key: string; context: string; url: string } + contentBase64?: string + sizeBytes: number + } + } + + expect(mockUploadExecutionFile).not.toHaveBeenCalled() + expect(mockUploadCopilotFile).toHaveBeenCalledWith({ + buffer: Buffer.from(bytes), + fileName: 'presentation-1.pdf', + contentType: 'application/pdf', + userId: 'user-1', + }) + expect(result.output.file).toMatchObject({ + key: 'copilot/copilot-file-1', + context: 'copilot', + url: '/api/files/serve/copilot/copilot-file-1', + }) + expect(result.output.contentBase64).toBe(Buffer.from(bytes).toString('base64')) + expect(result.output.sizeBytes).toBe(bytes.byteLength) + }) + + it('maps internal API responses into tool output', async () => { + const response = new Response( + JSON.stringify({ + success: true, + output: { + file: { + key: 'copilot/copilot-file-1', + context: 'copilot', + url: '/api/files/serve/copilot/copilot-file-1', + }, + mimeType: 'application/pdf', + sizeBytes: 3, + metadata: { + presentationId: 'presentation-1', + url: 'https://docs.google.com/presentation/d/presentation-1/edit', + exportFormat: 'PDF', + }, + }, + }), + { + status: 200, + headers: { 'content-type': 'application/json' }, + } + ) + + const result = await exportPresentationTool.transformResponse?.(response) + + expect(result?.output.file?.key).toBe('copilot/copilot-file-1') + expect(result?.output.metadata.presentationId).toBe('presentation-1') + }) +}) diff --git a/apps/sim/tools/google_slides/export_presentation.ts b/apps/sim/tools/google_slides/export_presentation.ts index 3455314941..1dc3c1b814 100644 --- a/apps/sim/tools/google_slides/export_presentation.ts +++ b/apps/sim/tools/google_slides/export_presentation.ts @@ -1,35 +1,24 @@ -import { createLogger } from '@sim/logger' -import { presentationUrl } from '@/tools/google_slides/utils' +import type { UserFile } from '@/executor/types' import type { ToolConfig } from '@/tools/types' -const logger = createLogger('GoogleSlidesExportPresentationTool') - -interface ExportPresentationParams { +export interface ExportPresentationParams { accessToken: string presentationId: string exportFormat?: 'PDF' | 'PPTX' | 'ODP' | 'TXT' | 'PNG' | 'JPEG' | 'SVG' + _context?: Record } -interface ExportPresentationResponse { +export interface ExportPresentationResponse { success: boolean output: { - contentBase64: string + contentBase64?: string + file?: UserFile & { mimeType?: string } mimeType: string sizeBytes: number metadata: { presentationId: string; url: string; exportFormat: string } } } -const FORMAT_TO_MIME: Record = { - PDF: 'application/pdf', - PPTX: 'application/vnd.openxmlformats-officedocument.presentationml.presentation', - ODP: 'application/vnd.oasis.opendocument.presentation', - TXT: 'text/plain', - PNG: 'image/png', - JPEG: 'image/jpeg', - SVG: 'image/svg+xml', -} - export const exportPresentationTool: ToolConfig< ExportPresentationParams, ExportPresentationResponse @@ -37,7 +26,7 @@ export const exportPresentationTool: ToolConfig< id: 'google_slides_export_presentation', name: 'Export Google Slides Presentation', description: - 'Export a presentation to PDF, PPTX, ODP, TXT, PNG, JPEG, or SVG via the Drive export endpoint. Returns the file content base64-encoded.', + 'Export a presentation to PDF, PPTX, ODP, TXT, PNG, JPEG, or SVG via the Drive export endpoint. Stores the exported file as an execution file when execution context is available.', version: '1.0.0', oauth: { required: true, provider: 'google-drive' }, @@ -64,58 +53,45 @@ export const exportPresentationTool: ToolConfig< }, request: { - url: (params) => { - const presentationId = params.presentationId?.trim() - if (!presentationId) throw new Error('Presentation ID is required') - const format = (params.exportFormat || 'PDF').toUpperCase() - const mime = FORMAT_TO_MIME[format] - if (!mime) throw new Error(`Unsupported export format: ${format}`) - return `https://www.googleapis.com/drive/v3/files/${presentationId}/export?mimeType=${encodeURIComponent(mime)}` - }, - method: 'GET', - headers: (params) => { - if (!params.accessToken) throw new Error('Access token is required') - return { Authorization: `Bearer ${params.accessToken}` } - }, + url: '/api/tools/google_slides/export-presentation', + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + accessToken: params.accessToken, + presentationId: params.presentationId, + exportFormat: params.exportFormat, + workspaceId: + typeof params._context?.workspaceId === 'string' ? params._context.workspaceId : undefined, + workflowId: + typeof params._context?.workflowId === 'string' ? params._context.workflowId : undefined, + executionId: + typeof params._context?.executionId === 'string' ? params._context.executionId : undefined, + }), }, - transformResponse: async (response: Response, params) => { - if (!response.ok) { - let errorMessage = `Failed to export presentation (status ${response.status})` - try { - const data = await response.json() - errorMessage = data.error?.message || errorMessage - logger.error('Drive API error during export:', { data }) - } catch { - // Body wasn't JSON — fall through with default error message. - } - throw new Error(errorMessage) + transformResponse: async (response: Response) => { + const data = await response.json() + if (!response.ok || data.success === false) { + throw new Error(data.error || 'Failed to export presentation') } - const buffer = await response.arrayBuffer() - const contentBase64 = Buffer.from(buffer).toString('base64') - - const presentationId = params?.presentationId?.trim() || '' - const format = (params?.exportFormat || 'PDF').toUpperCase() - const mime = FORMAT_TO_MIME[format] ?? 'application/octet-stream' - return { success: true, - output: { - contentBase64, - mimeType: mime, - sizeBytes: buffer.byteLength, - metadata: { - presentationId, - url: presentationUrl(presentationId), - exportFormat: format, - }, - }, + output: data.output, } }, outputs: { - contentBase64: { type: 'string', description: 'Base64-encoded exported file content' }, + file: { + type: 'file', + description: 'Stored exported presentation file', + optional: true, + }, + contentBase64: { + type: 'string', + description: 'Deprecated legacy inline content. New exports return file.', + optional: true, + }, mimeType: { type: 'string', description: 'MIME type of the exported content' }, sizeBytes: { type: 'number', description: 'Size of the exported content in bytes' }, metadata: { diff --git a/apps/sim/tools/http/request.test.ts b/apps/sim/tools/http/request.test.ts index 88c2e9086c..ad61256059 100644 --- a/apps/sim/tools/http/request.test.ts +++ b/apps/sim/tools/http/request.test.ts @@ -237,6 +237,20 @@ describe('HTTP Request Tool', () => { expect(result.output.headers).toHaveProperty('content-type') }) + it('should reject responses that exceed the workflow data cap', async () => { + const response = new Response('too large', { + status: 200, + headers: { + 'content-type': 'text/plain', + 'content-length': '10485761', + }, + }) + + await expect(requestTool.transformResponse?.(response, {} as any)).rejects.toMatchObject({ + name: 'PayloadSizeLimitError', + }) + }) + it('should handle POST requests with body', async () => { tester.setup({ result: 'success' }) diff --git a/apps/sim/tools/http/request.ts b/apps/sim/tools/http/request.ts index 4472a37faf..c4a94db75a 100644 --- a/apps/sim/tools/http/request.ts +++ b/apps/sim/tools/http/request.ts @@ -1,8 +1,11 @@ +import { readResponseTextWithLimit } from '@/lib/core/utils/stream-limits' import type { RequestParams, RequestResponse } from '@/tools/http/types' import { getDefaultHeaders, processUrl } from '@/tools/http/utils' import { transformTable } from '@/tools/shared/table' import type { ToolConfig } from '@/tools/types' +const MAX_HTTP_RESPONSE_BODY_BYTES = 10 * 1024 * 1024 + export const requestTool: ToolConfig = { id: 'http_request', name: 'HTTP Request', @@ -158,9 +161,12 @@ export const requestTool: ToolConfig = { headers[key] = value }) - const data = await (contentType.includes('application/json') - ? response.json() - : response.text()) + const responseText = await readResponseTextWithLimit(response, { + maxBytes: MAX_HTTP_RESPONSE_BODY_BYTES, + label: 'HTTP Request response body', + allowNoBodyFallback: true, + }) + const data = contentType.includes('application/json') ? JSON.parse(responseText) : responseText // Check if this is a proxy response (structured response from /api/proxy) if ( diff --git a/apps/sim/tools/index.test.ts b/apps/sim/tools/index.test.ts index 1a27cc552c..f014fc7236 100644 --- a/apps/sim/tools/index.test.ts +++ b/apps/sim/tools/index.test.ts @@ -744,6 +744,88 @@ describe('Automatic Internal Route Detection', () => { Object.assign(tools, originalTools) }) + it('should reject internal tool responses that exceed the response body cap', async () => { + const mockTool = { + id: 'test_oversized_internal_tool', + name: 'Test Oversized Internal Tool', + description: 'A test tool with an oversized response', + version: '1.0.0', + params: {}, + request: { + url: '/api/test/oversized', + method: 'GET', + }, + transformResponse: vi.fn().mockResolvedValue({ + success: true, + output: { result: 'should not run' }, + }), + } + + const originalTools = { ...tools } + ;(tools as any).test_oversized_internal_tool = mockTool + + global.fetch = Object.assign( + vi.fn().mockResolvedValue( + new Response('too large', { + status: 200, + headers: { + 'content-length': '10485761', + 'content-type': 'text/plain', + }, + }) + ), + { preconnect: vi.fn() } + ) as typeof fetch + + const result = await executeTool('test_oversized_internal_tool', {}) + + expect(result.success).toBe(false) + expect(result.error).toContain('response size limit exceeded') + expect(mockTool.transformResponse).not.toHaveBeenCalled() + + Object.assign(tools, originalTools) + }) + + it('preserves structured 413 errors from internal tool routes', async () => { + const mockTool = { + id: 'test_internal_route_413_tool', + name: 'Test Internal Route 413 Tool', + description: 'A test tool with a route-produced payload limit error', + version: '1.0.0', + params: {}, + request: { + url: '/api/test/payload-limit', + method: 'GET', + }, + transformResponse: vi.fn().mockResolvedValue({ + success: true, + output: { result: 'should not run' }, + }), + } + + const originalTools = { ...tools } + ;(tools as any).test_internal_route_413_tool = mockTool + + global.fetch = Object.assign( + vi.fn().mockResolvedValue( + new Response(JSON.stringify({ error: 'Generated image exceeds maximum size' }), { + status: 413, + headers: { 'content-type': 'application/json' }, + }) + ), + { preconnect: vi.fn() } + ) as typeof fetch + + const result = await executeTool('test_internal_route_413_tool', {}) + + expect(result.success).toBe(false) + expect(result.error).toContain('Generated image exceeds maximum size') + expect(result.error).not.toContain('Request body size limit exceeded') + expect(mockTool.transformResponse).not.toHaveBeenCalled() + + Object.assign(tools, originalTools) + }) + it('should detect external routes (full URLs) and call directly with SSRF protection', async () => { // This test verifies that external URLs are called directly (not via proxy) // with SSRF protection via secureFetchWithPinnedIP diff --git a/apps/sim/tools/index.ts b/apps/sim/tools/index.ts index de5adb1dc7..ce8357bba2 100644 --- a/apps/sim/tools/index.ts +++ b/apps/sim/tools/index.ts @@ -13,6 +13,10 @@ import { } from '@/lib/core/security/input-validation.server' import { PlatformEvents } from '@/lib/core/telemetry' import { generateRequestId } from '@/lib/core/utils/request' +import { + isPayloadSizeLimitError, + readResponseToBufferWithLimit, +} from '@/lib/core/utils/stream-limits' import { getBaseUrl, getInternalApiBaseUrl } from '@/lib/core/utils/urls' import { isUserFile } from '@/lib/core/utils/user-file' import { SIM_VIA_HEADER, serializeCallChain } from '@/lib/execution/call-chain' @@ -561,6 +565,7 @@ import { normalizeToolId } from '@/tools/normalize' * Next.js 16 has a default middleware/proxy body limit of 10MB. */ const MAX_REQUEST_BODY_SIZE_BYTES = 10 * 1024 * 1024 // 10MB +const MAX_TOOL_RESPONSE_BODY_BYTES = 10 * 1024 * 1024 // 10MB /** * User-friendly error message for body size limit exceeded @@ -568,6 +573,9 @@ const MAX_REQUEST_BODY_SIZE_BYTES = 10 * 1024 * 1024 // 10MB const BODY_SIZE_LIMIT_ERROR_MESSAGE = 'Request body size limit exceeded (10MB). The workflow data is too large to process. Try reducing the size of variables, inputs, or data being passed between blocks.' +const RESPONSE_SIZE_LIMIT_ERROR_MESSAGE = + 'Tool response size limit exceeded (10MB). The response is too large to keep in workflow data. Reduce the response size or return a file reference instead.' + /** * Validates request body size and throws a user-friendly error if exceeded * @param body - The request body string to check @@ -634,6 +642,67 @@ function handleBodySizeLimitError(error: unknown, requestId: string, context: st return false } +function handleResponseSizeLimitError(error: unknown, requestId: string, context: string): boolean { + if (!isPayloadSizeLimitError(error)) return false + + logger.error(`[${requestId}] Response body size limit exceeded for ${context}:`, { + label: error.label, + maxBytes: error.maxBytes, + observedBytes: error.observedBytes, + }) + throw new Error(RESPONSE_SIZE_LIMIT_ERROR_MESSAGE) +} + +function cloneResponseHeaders(headers: Headers | HeadersInit | undefined): Headers { + const clonedHeaders = new Headers() + if (!headers) return clonedHeaders + + if (typeof (headers as Headers).forEach === 'function') { + ;(headers as Headers).forEach((value, key) => { + clonedHeaders.set(key, value) + }) + return clonedHeaders + } + + return new Headers(headers) +} + +async function readToolResponseBody( + response: { + ok?: boolean + headers?: { get(name: string): string | null } + body?: ReadableStream | null + arrayBuffer?: () => Promise + text?: () => Promise + }, + options: { + requestId: string + toolId: string + signal?: AbortSignal + } +): Promise { + try { + return await readResponseToBufferWithLimit(response, { + maxBytes: MAX_TOOL_RESPONSE_BODY_BYTES, + label: `${options.toolId} response body`, + signal: options.signal, + allowNoBodyFallback: true, + }) + } catch (error) { + if (isPayloadSizeLimitError(error) || response.ok !== false) { + throw error + } + + logger.warn( + `[${options.requestId}] Failed to read non-OK response body for ${options.toolId}`, + { + error: toError(error).message, + } + ) + return Buffer.alloc(0) + } +} + /** * System parameters that should be filtered out when extracting tool arguments * These are internal parameters used by the execution framework, not tool inputs @@ -1299,6 +1368,18 @@ function parseRetryAfterHeader(header: string | null): number { return 0 } +function shouldRetryWithoutReadingBody( + status: number, + headers: { get(name: string): string | null }, + retryConfig: ResolvedRetryConfig | null | undefined, + isLastAttempt: boolean +): boolean { + if (!retryConfig || isLastAttempt || !isRetryableFailure(null, status)) { + return false + } + return parseRetryAfterHeader(headers.get('retry-after')) <= retryConfig.maxDelayMs +} + /** * Execute a tool request directly * Internal routes (/api/...) use regular fetch @@ -1392,6 +1473,7 @@ async function executeToolRequest( let response: Response | undefined let lastError: unknown + const nullBodyStatuses = new Set([101, 204, 205, 304]) for (let attempt = 0; attempt < maxAttempts; attempt++) { const isLastAttempt = attempt === maxAttempts - 1 @@ -1416,12 +1498,39 @@ async function executeToolRequest( } try { - response = await fetch(fullUrl, { + const internalResponse = await fetch(fullUrl, { method: requestParams.method, headers: headers, body: requestParams.body, signal: controller.signal, }) + if ( + nullBodyStatuses.has(internalResponse.status) || + shouldRetryWithoutReadingBody( + internalResponse.status, + internalResponse.headers, + retryConfig, + isLastAttempt + ) + ) { + internalResponse.body?.cancel().catch(() => {}) + response = new Response(null, { + status: internalResponse.status, + statusText: internalResponse.statusText, + headers: cloneResponseHeaders(internalResponse.headers), + }) + } else { + const bodyBuffer = await readToolResponseBody(internalResponse, { + requestId, + toolId, + signal: controller.signal, + }) + response = new Response(new Uint8Array(bodyBuffer), { + status: internalResponse.status, + statusText: internalResponse.statusText, + headers: cloneResponseHeaders(internalResponse.headers), + }) + } } catch (error) { if (error instanceof Error && error.name === 'AbortError') { // Distinguish caller cancellation from local timeout: rethrow the AbortError @@ -1449,21 +1558,34 @@ async function executeToolRequest( headers: headersRecord, body: requestParams.body ?? undefined, timeout: requestParams.timeout, + maxResponseBytes: MAX_TOOL_RESPONSE_BODY_BYTES, signal, }) const responseHeaders = new Headers(secureResponse.headers.toRecord()) - const nullBodyStatuses = new Set([101, 204, 205, 304]) - if (nullBodyStatuses.has(secureResponse.status)) { + if ( + nullBodyStatuses.has(secureResponse.status) || + shouldRetryWithoutReadingBody( + secureResponse.status, + responseHeaders, + retryConfig, + isLastAttempt + ) + ) { + secureResponse.body?.cancel().catch(() => {}) response = new Response(null, { status: secureResponse.status, statusText: secureResponse.statusText, headers: responseHeaders, }) } else { - const bodyBuffer = await secureResponse.arrayBuffer() - response = new Response(bodyBuffer, { + const bodyBuffer = await readToolResponseBody(secureResponse, { + requestId, + toolId, + signal, + }) + response = new Response(new Uint8Array(bodyBuffer), { status: secureResponse.status, statusText: secureResponse.statusText, headers: responseHeaders, @@ -1484,7 +1606,7 @@ async function executeToolRequest( `[${requestId}] Retrying ${toolId} after error (attempt ${attempt + 1}/${maxAttempts})`, { delayMs } ) - await new Promise((r) => setTimeout(r, delayMs)) + await sleep(delayMs) continue } @@ -1517,7 +1639,7 @@ async function executeToolRequest( `[${requestId}] Retrying ${toolId} after HTTP ${response.status} (attempt ${attempt + 1}/${maxAttempts})`, { delayMs } ) - await new Promise((r) => setTimeout(r, delayMs)) + await sleep(delayMs) continue } @@ -1528,29 +1650,18 @@ async function executeToolRequest( throw lastError ?? new Error(`Request failed for ${toolId}`) } - // For non-OK responses, attempt JSON first; if parsing fails, fall back to text if (!response.ok) { - // Check for 413 (Entity Too Large) - body size limit exceeded - if (response.status === 413) { - logger.error(`[${requestId}] Request body too large for ${toolId} (HTTP 413):`, { - status: response.status, - statusText: response.statusText, - }) - throw new Error(BODY_SIZE_LIMIT_ERROR_MESSAGE) - } - let errorData: any try { - errorData = await response.json() - } catch (jsonError) { - // JSON parsing failed, fall back to reading as text for error extraction - logger.warn(`[${requestId}] Response is not JSON for ${toolId}, reading as text`) + const errorText = await response.text() try { - errorData = await response.text() - } catch (textError) { - logger.error(`[${requestId}] Failed to read response body for ${toolId}`) - errorData = null + errorData = JSON.parse(errorText) + } catch { + errorData = errorText } + } catch { + logger.error(`[${requestId}] Failed to read response body for ${toolId}`) + errorData = null } const errorInfo: ErrorInfo = { @@ -1560,6 +1671,20 @@ async function executeToolRequest( } const errorToTransform = createTransformedErrorFromErrorInfo(errorInfo, tool.errorExtractor) + const hasStructuredErrorPayload = + errorData !== null && + typeof errorData === 'object' && + !Array.isArray(errorData) && + ('error' in errorData || 'message' in errorData) + + if (response.status === 413 && !hasStructuredErrorPayload) { + logger.error(`[${requestId}] Request body too large for ${toolId} (HTTP 413):`, { + status: response.status, + statusText: response.statusText, + errorData, + }) + throw new Error(BODY_SIZE_LIMIT_ERROR_MESSAGE) + } logger.error(`[${requestId}] Internal API error for ${toolId}:`, { status: errorInfo.status, @@ -1636,6 +1761,8 @@ async function executeToolRequest( error: undefined, } } catch (error: any) { + handleResponseSizeLimitError(error, requestId, toolId) + // Check if this is a body size limit error and throw user-friendly message handleBodySizeLimitError(error, requestId, toolId) diff --git a/apps/sim/tools/typeform/files.test.ts b/apps/sim/tools/typeform/files.test.ts new file mode 100644 index 0000000000..2ef2ad1ffa --- /dev/null +++ b/apps/sim/tools/typeform/files.test.ts @@ -0,0 +1,241 @@ +/** + * @vitest-environment node + */ +import { + createMockRequest, + hybridAuthMockFns, + inputValidationMock, + inputValidationMockFns, +} from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockUploadCopilotFile, mockUploadExecutionFile } = vi.hoisted(() => ({ + mockUploadCopilotFile: vi.fn(), + mockUploadExecutionFile: vi.fn(), +})) + +vi.mock('@/lib/core/security/input-validation.server', () => inputValidationMock) +vi.mock('@/lib/uploads/contexts/copilot', () => ({ + uploadCopilotFile: mockUploadCopilotFile, +})) +vi.mock('@/lib/uploads/contexts/execution', () => ({ + uploadExecutionFile: mockUploadExecutionFile, +})) + +import { POST } from '@/app/api/tools/typeform/files/route' +import { filesTool } from '@/tools/typeform/files' +import type { TypeformFilesParams } from '@/tools/typeform/types' + +describe('Typeform files tool', () => { + beforeEach(() => { + vi.clearAllMocks() + hybridAuthMockFns.mockCheckInternalAuth.mockResolvedValue({ + success: true, + userId: 'user-1', + authType: 'internal_jwt', + }) + inputValidationMockFns.mockValidateUrlWithDNS.mockResolvedValue({ + isValid: true, + resolvedIP: '93.184.216.34', + originalHostname: 'api.typeform.com', + }) + mockUploadExecutionFile.mockResolvedValue({ + id: 'file-1', + name: 'upload.pdf', + size: 7, + type: 'application/pdf', + url: '/api/files/serve/execution/file-1', + key: 'execution/workflow/file-1', + context: 'execution', + }) + mockUploadCopilotFile.mockResolvedValue({ + id: 'copilot-file-1', + name: 'upload.pdf', + size: 4, + type: 'application/pdf', + mimeType: 'application/pdf', + url: '/api/files/serve/copilot/copilot-file-1', + key: 'copilot/copilot-file-1', + context: 'copilot', + }) + }) + + it('routes file downloads through the internal API with execution context', () => { + const params: TypeformFilesParams = { + formId: 'form-1', + responseId: 'response-1', + fieldId: 'field-1', + filename: 'upload.pdf', + apiKey: 'token', + _context: { + workspaceId: 'workspace-1', + workflowId: 'workflow-1', + executionId: 'execution-1', + }, + } + + expect(filesTool.request.url).toBe('/api/tools/typeform/files') + expect(filesTool.request.method).toBe('POST') + expect(filesTool.request.body?.(params)).toEqual({ + formId: 'form-1', + responseId: 'response-1', + fieldId: 'field-1', + filename: 'upload.pdf', + inline: undefined, + apiKey: 'token', + workspaceId: 'workspace-1', + workflowId: 'workflow-1', + executionId: 'execution-1', + }) + }) + + it('stores downloaded files as execution file references', async () => { + inputValidationMockFns.mockSecureFetchWithPinnedIP.mockResolvedValueOnce( + new Response('content', { + status: 200, + headers: { + 'content-type': 'application/pdf', + 'content-disposition': 'attachment; filename="upload.pdf"', + }, + }) + ) + + const response = await POST( + createMockRequest('POST', { + formId: 'form-1', + responseId: 'response-1', + fieldId: 'field-1', + filename: 'upload.pdf', + apiKey: 'token', + workspaceId: 'workspace-1', + workflowId: 'workflow-1', + executionId: 'execution-1', + }) + ) + const result = (await response.json()) as { + success: true + output: { file: { key: string; context: string; mimeType: string } } + } + + expect(response.status).toBe(200) + expect(inputValidationMockFns.mockSecureFetchWithPinnedIP).toHaveBeenCalledWith( + 'https://api.typeform.com/forms/form-1/responses/response-1/fields/field-1/files/upload.pdf', + '93.184.216.34', + expect.objectContaining({ + headers: { Authorization: 'Bearer token' }, + maxResponseBytes: 10 * 1024 * 1024, + }) + ) + expect(mockUploadExecutionFile).toHaveBeenCalledWith( + { workspaceId: 'workspace-1', workflowId: 'workflow-1', executionId: 'execution-1' }, + Buffer.from('content'), + 'upload.pdf', + 'application/pdf', + 'user-1' + ) + expect(result.output.file).toMatchObject({ + key: 'execution/workflow/file-1', + context: 'execution', + mimeType: 'application/pdf', + }) + expect(result.output.file).not.toHaveProperty('data') + }) + + it('stores downloads in copilot storage when execution context is unavailable', async () => { + const bytes = Uint8Array.from([0, 255, 1, 254]) + inputValidationMockFns.mockSecureFetchWithPinnedIP.mockResolvedValueOnce( + new Response(bytes, { + status: 200, + headers: { + 'content-type': 'application/pdf', + 'content-disposition': 'attachment; filename="upload.pdf"', + }, + }) + ) + + const response = await POST( + createMockRequest('POST', { + formId: 'form-1', + responseId: 'response-1', + fieldId: 'field-1', + filename: 'upload.pdf', + apiKey: 'token', + }) + ) + const result = (await response.json()) as { + success: true + output: { file: { key: string; context: string; url: string; size: number } } + } + + expect(response.status).toBe(200) + expect(mockUploadExecutionFile).not.toHaveBeenCalled() + expect(mockUploadCopilotFile).toHaveBeenCalledWith({ + buffer: Buffer.from(bytes), + fileName: 'upload.pdf', + contentType: 'application/pdf', + userId: 'user-1', + }) + expect(result.output.file).toMatchObject({ + key: 'copilot/copilot-file-1', + context: 'copilot', + url: '/api/files/serve/copilot/copilot-file-1', + size: 4, + }) + }) + + it('stores large downloads in copilot storage when execution context is unavailable', async () => { + inputValidationMockFns.mockSecureFetchWithPinnedIP.mockResolvedValueOnce( + new Response(new Uint8Array(8 * 1024 * 1024), { + status: 200, + headers: { + 'content-type': 'application/pdf', + 'content-disposition': 'attachment; filename="upload.pdf"', + }, + }) + ) + + const response = await POST( + createMockRequest('POST', { + formId: 'form-1', + responseId: 'response-1', + fieldId: 'field-1', + filename: 'upload.pdf', + apiKey: 'token', + }) + ) + const result = (await response.json()) as { + success: true + output: { file: { key: string; context: string } } + } + + expect(response.status).toBe(200) + expect(mockUploadCopilotFile).toHaveBeenCalled() + expect(result.output.file).toMatchObject({ + key: 'copilot/copilot-file-1', + context: 'copilot', + }) + }) + + it('maps internal API responses into tool output', async () => { + const response = new Response( + JSON.stringify({ + success: true, + output: { + fileUrl: '/api/files/serve/execution/file-1', + file: { name: 'upload.pdf', mimeType: 'application/pdf', data: 'abc', size: 3 }, + contentType: 'application/pdf', + filename: 'upload.pdf', + }, + }), + { + status: 200, + headers: { 'content-type': 'application/json' }, + } + ) + + const result = await filesTool.transformResponse?.(response) + + expect(result?.output.filename).toBe('upload.pdf') + expect(result?.output.fileUrl).toBe('/api/files/serve/execution/file-1') + }) +}) diff --git a/apps/sim/tools/typeform/files.ts b/apps/sim/tools/typeform/files.ts index 6b0fe81e2f..95cee7c035 100644 --- a/apps/sim/tools/typeform/files.ts +++ b/apps/sim/tools/typeform/files.ts @@ -47,78 +47,36 @@ export const filesTool: ToolConfig = }, request: { - url: (params: TypeformFilesParams) => { - const encodedFormId = encodeURIComponent(params.formId) - const encodedResponseId = encodeURIComponent(params.responseId) - const encodedFieldId = encodeURIComponent(params.fieldId) - const encodedFilename = encodeURIComponent(params.filename) - - let url = `https://api.typeform.com/forms/${encodedFormId}/responses/${encodedResponseId}/fields/${encodedFieldId}/files/${encodedFilename}` - - // Add the inline parameter if provided - if (params.inline !== undefined) { - url += `?inline=${params.inline}` - } - - return url - }, - method: 'GET', - headers: (params) => ({ - Authorization: `Bearer ${params.apiKey}`, + url: '/api/tools/typeform/files', + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json', }), + body: (params) => ({ + formId: params.formId, + responseId: params.responseId, + fieldId: params.fieldId, + filename: params.filename, + inline: params.inline, + apiKey: params.apiKey, + workspaceId: + typeof params._context?.workspaceId === 'string' ? params._context.workspaceId : undefined, + workflowId: + typeof params._context?.workflowId === 'string' ? params._context.workflowId : undefined, + executionId: + typeof params._context?.executionId === 'string' ? params._context.executionId : undefined, + }), }, - transformResponse: async (response: Response, params?: TypeformFilesParams) => { - // For file downloads, we get the file directly - const contentType = response.headers.get('content-type') || 'application/octet-stream' - const contentDisposition = response.headers.get('content-disposition') || '' - const arrayBuffer = await response.arrayBuffer() - const buffer = Buffer.from(arrayBuffer) - - // Try to extract filename from content-disposition if possible - let filename = '' - const filenameMatch = contentDisposition.match(/filename="(.+?)"/) - if (filenameMatch?.[1]) { - filename = filenameMatch[1] - } - if (!filename && params?.filename) { - filename = params.filename - } - if (!filename) { - filename = 'typeform-file' - } - - // Get file URL from the response URL or construct it from parameters if not available - let fileUrl = response.url - - // If the response URL is not available (common in test environments), construct it from params - if (!fileUrl && params) { - const encodedFormId = encodeURIComponent(params.formId) - const encodedResponseId = encodeURIComponent(params.responseId) - const encodedFieldId = encodeURIComponent(params.fieldId) - const encodedFilename = encodeURIComponent(params.filename) - - fileUrl = `https://api.typeform.com/forms/${encodedFormId}/responses/${encodedResponseId}/fields/${encodedFieldId}/files/${encodedFilename}` - - if (params.inline !== undefined) { - fileUrl += `?inline=${params.inline}` - } + transformResponse: async (response: Response) => { + const data = await response.json() + if (!response.ok || data.success === false) { + throw new Error(data.error || 'Failed to download Typeform file') } return { success: true, - output: { - fileUrl: fileUrl || '', - file: { - name: filename, - mimeType: contentType, - data: buffer.toString('base64'), - size: buffer.length, - }, - contentType, - filename, - }, + output: data.output, } }, diff --git a/apps/sim/tools/typeform/types.ts b/apps/sim/tools/typeform/types.ts index c6f639a512..17179226e4 100644 --- a/apps/sim/tools/typeform/types.ts +++ b/apps/sim/tools/typeform/types.ts @@ -1,3 +1,4 @@ +import type { UserFile } from '@/executor/types' import type { ToolFileData, ToolResponse } from '@/tools/types' export interface TypeformFilesParams { @@ -7,12 +8,13 @@ export interface TypeformFilesParams { filename: string inline?: boolean apiKey: string + _context?: Record } export interface TypeformFilesResponse extends ToolResponse { output: { fileUrl: string - file: ToolFileData + file: (UserFile & { mimeType?: string }) | ToolFileData contentType: string filename: string } diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts index 3dc81bfbec..0ab7c97be1 100644 --- a/scripts/check-api-validation-contracts.ts +++ b/scripts/check-api-validation-contracts.ts @@ -9,8 +9,8 @@ const QUERY_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/queries') const SELECTOR_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/selectors') const BASELINE = { - totalRoutes: 749, - zodRoutes: 749, + totalRoutes: 751, + zodRoutes: 751, nonZodRoutes: 0, } as const From 4002242d84390a188be7323bfbdbc9c49e91cf70 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Thu, 21 May 2026 16:17:20 -0700 Subject: [PATCH 7/7] feat(tables): virtualize data grid with bounded copy and chunked delete (#4693) * feat(tables): virtualize data grid with bounded copy and chunked delete * fix(tables): keyboard scroll past sticky header, copy toast row count, clipboard error handling Co-Authored-By: Claude Opus 4.7 (1M context) * fix(tables): keep copy/cut progress toast until the operation completes Co-Authored-By: Claude Opus 4.7 (1M context) * fix(tables): stop bulk cut chunks on first failure, reconcile grid on error Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- .../components/table-grid/table-grid.tsx | 610 +++++++++++------- .../tables/[tableId]/hooks/use-table.ts | 44 ++ apps/sim/hooks/queries/tables.ts | 24 +- apps/sim/lib/table/constants.ts | 2 + apps/sim/package.json | 1 + bun.lock | 5 + 6 files changed, 446 insertions(+), 240 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx index 698c6b31e4..3b8dab0c1b 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx @@ -1,11 +1,12 @@ 'use client' import type React from 'react' -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { createLogger } from '@sim/logger' +import { useVirtualizer } from '@tanstack/react-virtual' import { useParams } from 'next/navigation' import { usePostHog } from 'posthog-js/react' -import { Skeleton, toast } from '@/components/emcn' +import { Skeleton, toast, useToast } from '@/components/emcn' import { TableX } from '@/components/emcn/icons' import type { RunMode } from '@/lib/api/contracts/tables' import { cn } from '@/lib/core/utils/cn' @@ -194,10 +195,18 @@ interface TableGridProps { > } +/** Serialize a cell value to its tab-separated clipboard representation. */ +function cellToText(value: unknown): string { + if (value === null || value === undefined) return '' + return typeof value === 'object' ? JSON.stringify(value) : String(value) +} + /** * Split updates into chunks bounded by the server batch-size limit, dispatching - * up to 3 chunks concurrently. Throws on first failure — `Promise.all` rejects - * immediately, so partial success cannot leave the table in an ambiguous state. + * up to 3 chunks concurrently. On the first chunk failure the remaining chunks + * are not dispatched and the error is rethrown. There is no cross-chunk + * transaction, so chunks already committed (or in flight when the failure + * occurs) are not rolled back — callers must reconcile on failure (e.g. refetch). */ async function chunkBatchUpdates( updates: Array<{ rowId: string; data: Record }>, @@ -211,10 +220,17 @@ async function chunkBatchUpdates( chunks.push(updates.slice(i, i + size)) } let cursor = 0 + let failed = false await Promise.all( Array.from({ length: Math.min(3, chunks.length) }, async () => { - while (cursor < chunks.length) { - await mutateAsync({ updates: chunks[cursor++]! }) + while (cursor < chunks.length && !failed) { + const chunk = chunks[cursor++]! + try { + await mutateAsync({ updates: chunk }) + } catch (error) { + failed = true + throw error + } } }) ) @@ -283,6 +299,8 @@ export function TableGrid({ const metadataSeededRef = useRef(false) const containerRef = useRef(null) const scrollRef = useRef(null) + const theadRef = useRef(null) + const tbodyRef = useRef(null) const isDraggingRef = useRef(false) const suppressFocusScrollRef = useRef(false) @@ -300,6 +318,8 @@ export function TableGrid({ workflowStates, columnSourceInfo, ensureAllRowsLoaded, + ensureRowsLoadedUpTo, + refetchRows, } = useTable({ workspaceId, tableId, queryOptions }) const { data: tableRunState } = useTableRunState(tableId) @@ -307,6 +327,9 @@ export function TableGrid({ const totalRunning = tableRunState?.runningCellCount ?? 0 const runningByRowId = tableRunState?.runningByRowId ?? EMPTY_RUNNING_BY_ROW + const tableRowCountRef = useRef(tableData?.rowCount ?? 0) + tableRowCountRef.current = tableData?.rowCount ?? 0 + const fetchNextPageRef = useRef(fetchNextPage) fetchNextPageRef.current = fetchNextPage const hasNextPageRef = useRef(hasNextPage) @@ -315,11 +338,61 @@ export function TableGrid({ isFetchingNextPageRef.current = isFetchingNextPage const ensureAllRowsLoadedRef = useRef(ensureAllRowsLoaded) ensureAllRowsLoadedRef.current = ensureAllRowsLoaded + const ensureRowsLoadedUpToRef = useRef(ensureRowsLoadedUpTo) + ensureRowsLoadedUpToRef.current = ensureRowsLoadedUpTo + const refetchRowsRef = useRef(refetchRows) + refetchRowsRef.current = refetchRows const isAppendingRowRef = useRef(false) + /** + * Row windowing. The native `` is preserved; only the visible slice + * (+ overscan) of ``s is rendered, with spacer rows sizing the off-screen + * remainder. `scrollMargin` accounts for the sticky `` that sits above + * the rows inside the same scroll container. Rows are fixed-height by design + * (see `CELL_CONTENT`), so a measured constant gives drift-free scrolling + * without per-row measurement. + */ + const [headerHeight, setHeaderHeight] = useState(0) + const [rowHeight, setRowHeight] = useState(ROW_HEIGHT_ESTIMATE) + + const rowVirtualizer = useVirtualizer({ + count: rows.length, + getScrollElement: () => scrollRef.current, + estimateSize: () => rowHeight, + overscan: 12, + scrollMargin: headerHeight, + getItemKey: (index) => rows[index]?.id ?? index, + }) + + useEffect(() => { + rowVirtualizer.measure() + }, [rowHeight, rowVirtualizer]) + + useLayoutEffect(() => { + const el = theadRef.current + if (!el) return + const measure = () => + setHeaderHeight((prev) => (prev === el.offsetHeight ? prev : el.offsetHeight)) + measure() + const observer = new ResizeObserver(measure) + observer.observe(el) + return () => observer.disconnect() + }, []) + + useLayoutEffect(() => { + if (isLoadingTable || isLoadingRows) return + const cell = tbodyRef.current?.querySelector('td[data-row]') + if (!cell) return + const measured = cell.getBoundingClientRect().height + if (measured > 0 && Math.abs(measured - rowHeight) >= 0.5) setRowHeight(measured) + }, [isLoadingTable, isLoadingRows, rowHeight]) + const userPermissions = useUserPermissionsContext() const canEditRef = useRef(userPermissions.canEdit) canEditRef.current = userPermissions.canEdit + const { dismiss: dismissToast } = useToast() + const dismissToastRef = useRef(dismissToast) + dismissToastRef.current = dismissToast // Refs for callback props read inside effects with stable empty deps. const onOpenRowModalRef = useRef(onOpenRowModal) onOpenRowModalRef.current = onOpenRowModal @@ -600,13 +673,27 @@ export function TableGrid({ const rowSel = rowSelectionRef.current const currentRows = rowsRef.current - let snapshots: DeletedRowSnapshot[] = [] const contextRowInRows = currentRows.some((r) => r.id === contextRow.id) + // Select-all delete covers every row matching the active filter, which may + // not all be loaded — drain pages first so the (chunked) delete spans the + // full set rather than only the loaded window. if (rowSel.kind === 'all' && contextRowInRows) { - snapshots = collectRowSnapshots(currentRows) - } else if (rowSel.kind === 'some' && rowSel.ids.has(contextRow.id)) { + closeContextMenu() + void (async () => { + const allRows = await ensureAllRowsLoadedRef.current() + const snapshots = collectRowSnapshots(allRows) + if (snapshots.length > 0) onRequestDeleteRows(snapshots) + })().catch((error) => { + logger.error('Failed to load rows for delete', { error }) + toast.error('Failed to delete rows — please try again') + }) + return + } + + let snapshots: DeletedRowSnapshot[] = [] + if (rowSel.kind === 'some' && rowSel.ids.has(contextRow.id)) { snapshots = collectRowSnapshots(currentRows.filter((r) => rowSel.ids.has(r.id))) } else { const sel = computeNormalizedSelection(selectionAnchorRef.current, selectionFocusRef.current) @@ -1414,14 +1501,47 @@ export function TableGrid({ const target = selectionFocus ?? selectionAnchor if (!target) return const { rowIndex, colIndex } = target + const selector = `[data-table-scroll] [data-row="${rowIndex}"][data-col="${colIndex}"]` + // `scrollIntoView` ignores the sticky `` and sticky gutter, so a cell + // scrolled to the edge lands behind them. Scroll manually with insets equal + // to the sticky header height (top) and the row-number column width (left). + const revealCell = (cell: HTMLElement) => { + const scrollEl = scrollRef.current + if (!scrollEl) return + const view = scrollEl.getBoundingClientRect() + const rect = cell.getBoundingClientRect() + const topInset = theadRef.current?.offsetHeight ?? 0 + if (rect.top < view.top + topInset) { + scrollEl.scrollTop -= view.top + topInset - rect.top + } else if (rect.bottom > view.bottom) { + scrollEl.scrollTop += rect.bottom - view.bottom + } + if (rect.left < view.left + checkboxColWidth) { + scrollEl.scrollLeft -= view.left + checkboxColWidth - rect.left + } else if (rect.right > view.right) { + scrollEl.scrollLeft += rect.right - view.right + } + } + let secondRaf = 0 const rafId = requestAnimationFrame(() => { - const cell = document.querySelector( - `[data-table-scroll] [data-row="${rowIndex}"][data-col="${colIndex}"]` - ) as HTMLElement | null - cell?.scrollIntoView({ block: 'nearest', inline: 'nearest' }) + const cell = document.querySelector(selector) as HTMLElement | null + if (cell) { + revealCell(cell) + return + } + // Target row is windowed out (large jump / PageUp-Down). Bring it into the + // virtualized range first, then align once it has rendered. + rowVirtualizer.scrollToIndex(rowIndex, { align: 'auto' }) + secondRaf = requestAnimationFrame(() => { + const rendered = document.querySelector(selector) as HTMLElement | null + if (rendered) revealCell(rendered) + }) }) - return () => cancelAnimationFrame(rafId) - }, [selectionAnchor, selectionFocus, isColumnSelection]) + return () => { + cancelAnimationFrame(rafId) + if (secondRaf) cancelAnimationFrame(secondRaf) + } + }, [selectionAnchor, selectionFocus, isColumnSelection, rowVirtualizer, checkboxColWidth]) const handleCellClick = useCallback( (rowId: string, columnName: string, options?: { toggleBoolean?: boolean }) => { @@ -1878,6 +1998,123 @@ export function TableGrid({ } } + /** + * Copies/cuts a selection that may span every row by paging through the + * table (capped at {@link TABLE_LIMITS.MAX_COPY_ROWS}). The promise-based + * `ClipboardItem` is what makes this safe: `.write()` is invoked + * synchronously within the copy/cut gesture so its transient activation + * survives the async page load — a plain `await writeText(...)` after paging + * loses the gesture and is rejected. Past the cap, copies the first + * `MAX_COPY_ROWS` and points the user at Export CSV. + */ + const writeSelectionToClipboard = (opts: { + loadRows: () => Promise<{ rows: TableRowType[]; hasMore: boolean }> + selectRow: (row: TableRowType) => boolean + buildCells: (row: TableRowType) => string[] + verb: 'Copied' | 'Cut' + /** Best-known row count for the in-progress toast (exact count is shown on completion). */ + estimatedCount: number + afterCopy?: (copiedRows: TableRowType[]) => Promise | void + }) => { + if (typeof ClipboardItem === 'undefined' || !navigator.clipboard) { + toast.error('Clipboard access is unavailable in this context') + return + } + const isCopy = opts.verb === 'Copied' + const verbLower = isCopy ? 'copy' : 'cut' + const estimate = opts.estimatedCount + // duration:0 keeps the in-progress toast up through long page loads; it is + // dismissed explicitly on every settle path below. + const loadingToastId = toast({ + message: `${isCopy ? 'Copying' : 'Cutting'} ${estimate.toLocaleString()} ${estimate === 1 ? 'row' : 'rows'}…`, + duration: 0, + }) + let rowCount = 0 + let truncated = false + const copiedRows: TableRowType[] = [] + const blob = (async () => { + const { rows: loaded, hasMore } = await opts.loadRows() + const lines: string[] = [] + for (const row of loaded) { + if (!opts.selectRow(row)) continue + if (lines.length >= TABLE_LIMITS.MAX_COPY_ROWS) { + truncated = true + break + } + lines.push(opts.buildCells(row).join('\t')) + copiedRows.push(row) + } + truncated = truncated || hasMore + rowCount = lines.length + return new Blob([lines.join('\n')], { type: 'text/plain' }) + })() + // `.write()` is invoked synchronously so the copy/cut gesture's transient + // activation survives the async row load inside the blob promise. + const writePromise = navigator.clipboard.write([new ClipboardItem({ 'text/plain': blob })]) + void (async () => { + try { + await writePromise + } catch (error) { + // Rejects if the row load failed or the payload is too large for the + // clipboard — either way nothing landed, so report a plain failure + // rather than implying a size cap was hit. + logger.error(`Failed to ${verbLower} rows`, { error }) + dismissToastRef.current(loadingToastId) + toast.error(`Failed to ${verbLower} — please try again`) + return + } + // The clipboard now holds the data; a clear failure must not be reported + // as a copy/cut failure. + try { + await opts.afterCopy?.(copiedRows) + } catch (error) { + logger.error('Failed to clear cut cells', { error }) + dismissToastRef.current(loadingToastId) + toast.error('Copied to clipboard, but clearing the cells failed — please try again') + return + } + dismissToastRef.current(loadingToastId) + if (truncated) { + toast({ + message: `${opts.verb} first ${TABLE_LIMITS.MAX_COPY_ROWS.toLocaleString()} rows — export to CSV for the rest`, + }) + } else { + toast.success( + `${opts.verb} ${rowCount.toLocaleString()} ${rowCount === 1 ? 'row' : 'rows'}` + ) + } + })() + } + + /** + * Clears `colNames` on `rowsToClear` (the cut tail). Undo is recorded only + * after the whole clear succeeds — a large cut spans multiple non-atomic + * chunks, so on failure we drop the (now-unreliable) undo and refetch to + * reconcile the grid with whatever the server actually committed. + */ + const clearCutRows = async (rowsToClear: TableRowType[], colNames: string[]) => { + const undo: Array<{ rowId: string; data: Record }> = [] + const updates: Array<{ rowId: string; data: Record }> = [] + for (const row of rowsToClear) { + const previousData: Record = {} + const nextData: Record = {} + for (const name of colNames) { + previousData[name] = row.data[name] ?? null + nextData[name] = null + } + undo.push({ rowId: row.id, data: previousData }) + updates.push({ rowId: row.id, data: nextData }) + } + if (updates.length === 0) return + try { + await chunkBatchUpdates(updates, batchUpdateAsyncRef.current) + } catch (error) { + refetchRowsRef.current() + throw error + } + pushUndoRef.current({ type: 'clear-cells', cells: undo }) + } + const handleCopy = (e: ClipboardEvent) => { const tag = (e.target as HTMLElement).tagName if (tag === 'INPUT' || tag === 'TEXTAREA') return @@ -1889,36 +2126,15 @@ export function TableGrid({ if (!rowSelectionIsEmpty(rowSel)) { e.preventDefault() - void (async () => { - const allRows = await ensureAllRowsLoadedRef.current() - const lines: string[] = [] - for (const row of allRows) { - if (!rowSelectionIncludes(rowSel, row.id)) continue - const cells: string[] = cols.map((col) => { - const value: unknown = row.data[col.name] - if (value === null || value === undefined) return '' - return typeof value === 'object' ? JSON.stringify(value) : String(value) - }) - lines.push(cells.join('\t')) - } - if (!navigator.clipboard) { - toast.error('Clipboard access is unavailable in this context') - return - } - try { - await navigator.clipboard.writeText(lines.join('\n')) - } catch (err) { - if (err instanceof DOMException && err.name === 'NotAllowedError') { - toast.error( - 'Clipboard permission expired — press Cmd+C again immediately after selecting' - ) - } else { - throw err - } - } - })().catch((error) => { - logger.error('Failed to copy selected rows', { error }) - toast.error('Failed to copy — please try again') + writeSelectionToClipboard({ + loadRows: + rowSel.kind === 'all' + ? () => ensureRowsLoadedUpToRef.current(TABLE_LIMITS.MAX_COPY_ROWS) + : async () => ({ rows: rowsRef.current, hasMore: false }), + selectRow: (row) => rowSelectionIncludes(rowSel, row.id), + buildCells: (row) => cols.map((col) => cellToText(row.data[col.name])), + verb: 'Copied', + estimatedCount: rowSel.kind === 'some' ? rowSel.ids.size : tableRowCountRef.current, }) return } @@ -1932,45 +2148,17 @@ export function TableGrid({ e.preventDefault() if (isColumnSelectionRef.current) { - // Column-header copy spans all rows — drain pages first, then use async - // clipboard so we don't block the event before the drain completes. - void (async () => { - const allRows = await ensureAllRowsLoadedRef.current() - const lines: string[] = [] - for (const row of allRows) { - const cells: string[] = [] - for (let c = sel.startCol; c <= sel.endCol; c++) { - const colName = cols[c]?.name - if (!colName) continue - const value: unknown = row.data[colName] - cells.push( - value === null || value === undefined - ? '' - : typeof value === 'object' - ? JSON.stringify(value) - : String(value) - ) - } - lines.push(cells.join('\t')) - } - if (!navigator.clipboard) { - toast.error('Clipboard access is unavailable in this context') - return - } - try { - await navigator.clipboard.writeText(lines.join('\n')) - } catch (err) { - if (err instanceof DOMException && err.name === 'NotAllowedError') { - toast.error( - 'Clipboard permission expired — press Cmd+C again immediately after selecting' - ) - } else { - throw err - } - } - })().catch((error) => { - logger.error('Failed to copy column cells', { error }) - toast.error('Failed to copy — please try again') + const colNames: string[] = [] + for (let c = sel.startCol; c <= sel.endCol; c++) { + const name = cols[c]?.name + if (name) colNames.push(name) + } + writeSelectionToClipboard({ + loadRows: () => ensureRowsLoadedUpToRef.current(TABLE_LIMITS.MAX_COPY_ROWS), + selectRow: () => true, + buildCells: (row) => colNames.map((name) => cellToText(row.data[name])), + verb: 'Copied', + estimatedCount: tableRowCountRef.current, }) return } @@ -1981,12 +2169,7 @@ export function TableGrid({ for (let c = sel.startCol; c <= sel.endCol; c++) { if (c >= cols.length) break const row = currentRows[r] - const value: unknown = row ? row.data[cols[c].name] : null - if (value === null || value === undefined) { - cells.push('') - } else { - cells.push(typeof value === 'object' ? JSON.stringify(value) : String(value)) - } + cells.push(row ? cellToText(row.data[cols[c].name]) : '') } lines.push(cells.join('\t')) } @@ -2005,52 +2188,20 @@ export function TableGrid({ if (!rowSelectionIsEmpty(rowSel)) { e.preventDefault() - void (async () => { - const allRows = await ensureAllRowsLoadedRef.current() - const lines: string[] = [] - const cutUpdates: Array<{ rowId: string; data: Record }> = [] - const cutUndo: Array<{ rowId: string; data: Record }> = [] - for (const row of allRows) { - if (!rowSelectionIncludes(rowSel, row.id)) continue - const cells: string[] = cols.map((col) => { - const value: unknown = row.data[col.name] - if (value === null || value === undefined) return '' - return typeof value === 'object' ? JSON.stringify(value) : String(value) - }) - lines.push(cells.join('\t')) - const updates: Record = {} - const previousData: Record = {} - for (const col of cols) { - previousData[col.name] = row.data[col.name] ?? null - updates[col.name] = null - } - cutUndo.push({ rowId: row.id, data: previousData }) - cutUpdates.push({ rowId: row.id, data: updates }) - } - if (!navigator.clipboard) { - toast.error('Clipboard access is unavailable in this context') - return - } - try { - await navigator.clipboard.writeText(lines.join('\n')) - } catch (err) { - if (err instanceof DOMException && err.name === 'NotAllowedError') { - toast.error( - 'Clipboard permission expired — press Cmd+X again immediately after selecting' - ) - return - } - throw err - } - if (cutUndo.length > 0) { - pushUndoRef.current({ type: 'clear-cells', cells: cutUndo }) - } - if (cutUpdates.length > 0) { - await chunkBatchUpdates(cutUpdates, batchUpdateAsyncRef.current) - } - })().catch((error) => { - logger.error('Failed to cut selected rows', { error }) - toast.error('Failed to cut — please try again') + writeSelectionToClipboard({ + loadRows: + rowSel.kind === 'all' + ? () => ensureRowsLoadedUpToRef.current(TABLE_LIMITS.MAX_COPY_ROWS) + : async () => ({ rows: rowsRef.current, hasMore: false }), + selectRow: (row) => rowSelectionIncludes(rowSel, row.id), + buildCells: (row) => cols.map((col) => cellToText(row.data[col.name])), + verb: 'Cut', + estimatedCount: rowSel.kind === 'some' ? rowSel.ids.size : tableRowCountRef.current, + afterCopy: (copied) => + clearCutRows( + copied, + cols.map((c) => c.name) + ), }) return } @@ -2064,57 +2215,18 @@ export function TableGrid({ e.preventDefault() if (isColumnSelectionRef.current) { - // Column-header cut spans all rows — drain pages first, then use async - // clipboard so we don't block the event before the drain completes. - void (async () => { - const allRows = await ensureAllRowsLoadedRef.current() - const lines: string[] = [] - const undoCells: Array<{ rowId: string; data: Record }> = [] - const batchUpdates: Array<{ rowId: string; data: Record }> = [] - for (const row of allRows) { - const cells: string[] = [] - const updates: Record = {} - const previousData: Record = {} - for (let c = sel.startCol; c <= sel.endCol; c++) { - const colName = cols[c]?.name - if (!colName) continue - const value: unknown = row.data[colName] - cells.push( - value === null || value === undefined - ? '' - : typeof value === 'object' - ? JSON.stringify(value) - : String(value) - ) - previousData[colName] = row.data[colName] ?? null - updates[colName] = null - } - lines.push(cells.join('\t')) - undoCells.push({ rowId: row.id, data: previousData }) - batchUpdates.push({ rowId: row.id, data: updates }) - } - if (!navigator.clipboard) { - toast.error('Clipboard access is unavailable in this context') - return - } - try { - await navigator.clipboard.writeText(lines.join('\n')) - } catch (err) { - if (err instanceof DOMException && err.name === 'NotAllowedError') { - toast.error( - 'Clipboard permission expired — press Cmd+X again immediately after selecting' - ) - return - } - throw err - } - if (undoCells.length > 0) { - pushUndoRef.current({ type: 'clear-cells', cells: undoCells }) - } - await chunkBatchUpdates(batchUpdates, batchUpdateAsyncRef.current) - })().catch((error) => { - logger.error('Failed to cut column cells', { error }) - toast.error('Failed to cut — please try again') + const colNames: string[] = [] + for (let c = sel.startCol; c <= sel.endCol; c++) { + const name = cols[c]?.name + if (name) colNames.push(name) + } + writeSelectionToClipboard({ + loadRows: () => ensureRowsLoadedUpToRef.current(TABLE_LIMITS.MAX_COPY_ROWS), + selectRow: () => true, + buildCells: (row) => colNames.map((name) => cellToText(row.data[name])), + verb: 'Cut', + estimatedCount: tableRowCountRef.current, + afterCopy: (copied) => clearCutRows(copied, colNames), }) return } @@ -2131,12 +2243,7 @@ export function TableGrid({ for (let c = sel.startCol; c <= sel.endCol; c++) { if (c < cols.length) { const colName = cols[c].name - const value: unknown = row.data[colName] - if (value === null || value === undefined) { - cells.push('') - } else { - cells.push(typeof value === 'object' ? JSON.stringify(value) : String(value)) - } + cells.push(cellToText(row.data[colName])) previousData[colName] = row.data[colName] ?? null updates[colName] = null } @@ -2986,7 +3093,7 @@ export function TableGrid({ checkboxColWidth={checkboxColWidth} /> )} - + {isLoadingTable ? ( - + {isLoadingTable || isLoadingRows ? ( ) : ( - <> - {rows.map((row, index) => ( - - ))} - + (() => { + const virtualItems = rowVirtualizer.getVirtualItems() + // `item.start`/`item.end` include `scrollMargin` (the sticky-header + // offset) but `getTotalSize()` already nets it out, so both spacer + // heights are computed relative to `scrollMargin`. + const scrollMargin = rowVirtualizer.options.scrollMargin + const paddingTop = + virtualItems.length > 0 ? virtualItems[0].start - scrollMargin : 0 + const paddingBottom = + virtualItems.length > 0 + ? rowVirtualizer.getTotalSize() - + (virtualItems[virtualItems.length - 1].end - scrollMargin) + : 0 + return ( + <> + {paddingTop > 0 && ( + + + )} + {virtualItems.map((virtualRow) => { + const index = virtualRow.index + const row = rows[index] + if (!row) return null + return ( + + ) + })} + {paddingBottom > 0 && ( + + + )} + + ) + })() )}
@@ -3128,47 +3235,86 @@ export function TableGrid({ )}
+
+
diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/hooks/use-table.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/hooks/use-table.ts index 2b36bda1a9..9dceb23fc2 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/hooks/use-table.ts +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/hooks/use-table.ts @@ -57,6 +57,13 @@ export interface UseTableReturn { * need the complete row set behind this. */ ensureAllRowsLoaded: () => Promise + /** + * Pages until the cache holds at least `maxRows` rows (or no more pages + * exist), then returns the first `maxRows` from cache plus whether more + * remain. Unlike {@link ensureAllRowsLoaded} it stops early, so size-bound + * ops (clipboard copy) don't drain an entire large table. Filter/sort-aware. + */ + ensureRowsLoadedUpTo: (maxRows: number) => Promise<{ rows: TableRow[]; hasMore: boolean }> } /** @@ -119,6 +126,42 @@ export function useTable({ workspaceId, tableId, queryOptions }: UseTableParams) return queryClient.getQueryData(opts.queryKey)?.pages.flatMap((p) => p.rows) ?? [] }, [workspaceId, tableId, queryOptions.filter, queryOptions.sort, queryClient, fetchNextPage]) + const ensureRowsLoadedUpTo = useCallback( + async (maxRows: number): Promise<{ rows: TableRow[]; hasMore: boolean }> => { + if (!workspaceId || !tableId) return { rows: [], hasMore: false } + + const opts = tableRowsInfiniteOptions({ + workspaceId, + tableId, + pageSize: TABLE_LIMITS.MAX_QUERY_LIMIT, + filter: queryOptions.filter, + sort: queryOptions.sort, + }) + + // Load one past the cap so `hasMore` is exact: a full final page only + // *might* have a successor, so we confirm by loading row `maxRows + 1` + // rather than inferring truncation from page fullness. + while (true) { + const data = queryClient.getQueryData(opts.queryKey) + const loaded = data?.pages.reduce((sum, p) => sum + p.rows.length, 0) ?? 0 + if (loaded > maxRows) break + const lastPage = data?.pages[data.pages.length - 1] + if (!lastPage || lastPage.rows.length < TABLE_LIMITS.MAX_QUERY_LIMIT) break + const result = await fetchNextPage() + if (result.status === 'error') { + throw result.error ?? new Error('Failed to load table rows') + } + } + + const all = queryClient.getQueryData(opts.queryKey)?.pages.flatMap((p) => p.rows) ?? [] + return { + rows: all.length > maxRows ? all.slice(0, maxRows) : all, + hasMore: all.length > maxRows, + } + }, + [workspaceId, tableId, queryOptions.filter, queryOptions.sort, queryClient, fetchNextPage] + ) + const fetchNextPageWrapped = useCallback(async () => { const result = await fetchNextPage() if (result.status === 'error') { @@ -176,5 +219,6 @@ export function useTable({ workspaceId, tableId, queryOptions }: UseTableParams) workflowStates, columnSourceInfo, ensureAllRowsLoaded, + ensureRowsLoadedUpTo, } } diff --git a/apps/sim/hooks/queries/tables.ts b/apps/sim/hooks/queries/tables.ts index 3f3f3cd531..80230bce1b 100644 --- a/apps/sim/hooks/queries/tables.ts +++ b/apps/sim/hooks/queries/tables.ts @@ -71,6 +71,7 @@ import type { WorkflowGroupDependencies, WorkflowGroupOutput, } from '@/lib/table' +import { TABLE_LIMITS } from '@/lib/table/constants' import { areGroupDepsSatisfied, isExecInFlight, @@ -836,17 +837,24 @@ export function useDeleteTableRows({ workspaceId, tableId }: RowMutationContext) mutationFn: async (rowIds: string[]): Promise => { const uniqueRowIds = Array.from(new Set(rowIds)) - const response = await requestJson(deleteTableRowsContract, { - params: { tableId }, - body: { workspaceId, rowIds: uniqueRowIds }, - }) - - const deletedRowIds = response.data.deletedRowIds || [] - const missingRowIds = response.data.missingRowIds || [] + // The delete contract caps `rowIds` at MAX_BULK_OPERATION_SIZE, so large + // selections (e.g. "select all") are sent as sequential chunks. + const chunkSize = TABLE_LIMITS.MAX_BULK_OPERATION_SIZE + const deletedRowIds: string[] = [] + const missingRowIds: string[] = [] + for (let i = 0; i < uniqueRowIds.length; i += chunkSize) { + const chunk = uniqueRowIds.slice(i, i + chunkSize) + const response = await requestJson(deleteTableRowsContract, { + params: { tableId }, + body: { workspaceId, rowIds: chunk }, + }) + deletedRowIds.push(...(response.data.deletedRowIds || [])) + missingRowIds.push(...(response.data.missingRowIds || [])) + } if (missingRowIds.length > 0) { const failureCount = missingRowIds.length - const totalCount = response.data.requestedCount ?? uniqueRowIds.length + const totalCount = uniqueRowIds.length const successCount = deletedRowIds.length const firstMissing = missingRowIds[0] throw new Error( diff --git a/apps/sim/lib/table/constants.ts b/apps/sim/lib/table/constants.ts index a56ef2ca9e..00597130b7 100644 --- a/apps/sim/lib/table/constants.ts +++ b/apps/sim/lib/table/constants.ts @@ -24,6 +24,8 @@ export const TABLE_LIMITS = { MAX_BATCH_INSERT_SIZE: 1000, /** Maximum rows per bulk update/delete operation */ MAX_BULK_OPERATION_SIZE: 1000, + /** Maximum rows a single clipboard copy/cut serializes; beyond this the user is steered to Export. */ + MAX_COPY_ROWS: 50000, } as const /** diff --git a/apps/sim/package.json b/apps/sim/package.json index 47e10e5149..a99174b285 100644 --- a/apps/sim/package.json +++ b/apps/sim/package.json @@ -107,6 +107,7 @@ "@t3-oss/env-nextjs": "0.13.4", "@tanstack/react-query": "5.90.8", "@tanstack/react-query-devtools": "5.90.2", + "@tanstack/react-virtual": "3.13.24", "@trigger.dev/sdk": "4.4.3", "ajv": "8.18.0", "better-auth": "1.3.12", diff --git a/bun.lock b/bun.lock index d2dc430861..8dd5cb897e 100644 --- a/bun.lock +++ b/bun.lock @@ -161,6 +161,7 @@ "@t3-oss/env-nextjs": "0.13.4", "@tanstack/react-query": "5.90.8", "@tanstack/react-query-devtools": "5.90.2", + "@tanstack/react-virtual": "3.13.24", "@trigger.dev/sdk": "4.4.3", "ajv": "8.18.0", "better-auth": "1.3.12", @@ -1647,6 +1648,10 @@ "@tanstack/react-query-devtools": ["@tanstack/react-query-devtools@5.90.2", "", { "dependencies": { "@tanstack/query-devtools": "5.90.1" }, "peerDependencies": { "@tanstack/react-query": "^5.90.2", "react": "^18 || ^19" } }, "sha512-vAXJzZuBXtCQtrY3F/yUNJCV4obT/A/n81kb3+YqLbro5Z2+phdAbceO+deU3ywPw8B42oyJlp4FhO0SoivDFQ=="], + "@tanstack/react-virtual": ["@tanstack/react-virtual@3.13.24", "", { "dependencies": { "@tanstack/virtual-core": "3.14.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-aIJvz5OSkhNIhZIpYivrxrPTKYsjW9Uzy+sP/mx0S3sev2HyvPb7xmjbYvokzEpfgYHy/HjzJ2zFAETuUfgCpg=="], + + "@tanstack/virtual-core": ["@tanstack/virtual-core@3.14.0", "", {}, "sha512-JLANqGy/D6k4Ujmh8Tr25lGimuOXNiaVyXaCAZS0W+1390sADdGnyUdSWNIfd49gebtIxGMij4IktRVzrdr12Q=="], + "@testing-library/jest-dom": ["@testing-library/jest-dom@6.9.1", "", { "dependencies": { "@adobe/css-tools": "^4.4.0", "aria-query": "^5.0.0", "css.escape": "^1.5.1", "dom-accessibility-api": "^0.6.3", "picocolors": "^1.1.1", "redent": "^3.0.0" } }, "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA=="], "@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="],