From 49673055beb6a92a538cba2edf9eafa152f84c8c Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Fri, 29 May 2026 13:47:32 -0700 Subject: [PATCH 01/29] improvement(logs): object storage backed tracespans (#4787) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * improvement(logs): obj storage backed tracespans * fix storage write context * fix tests * address comments * address comments * chore(db): remove migration 0219 to regenerate after staging merge Drops the 0219_robust_shard SQL, its snapshot, and the journal entry so the trace-spans/cost schema migration can be regenerated on top of the latest staging migration chain (avoids a number collision with staging's migrations). Co-authored-by: Cursor * improvement(billing): accurate per-member usage via shared ledger helper Per-member/per-user usage in the org-member routes now adds the usage_log ledger to the currentPeriodCost baseline (which is no longer incremented), via a shared getOrgMemberLedgerByUser helper to avoid repeating the subscription→period→ledger lookup across the admin and member-facing routes. Co-authored-by: Cursor * regen migrations * update migration * address comments * more code cleanup * incorrect type cast --------- Co-authored-by: Cursor --- apps/sim/app/api/billing/route.ts | 1 - apps/sim/app/api/knowledge/utils.test.ts | 7 +- .../api/logs/execution/[executionId]/route.ts | 15 +- apps/sim/app/api/logs/export/route.ts | 41 +- apps/sim/app/api/logs/route.ts | 12 +- .../[id]/members/[memberId]/route.ts | 16 + .../api/organizations/[id]/members/route.ts | 14 + .../[id]/members/[memberId]/route.ts | 11 +- .../admin/organizations/[id]/members/route.ts | 12 +- .../admin/organizations/[id]/seats/route.ts | 10 - apps/sim/app/api/v1/admin/types.ts | 26 +- .../api/v1/admin/users/[id]/billing/route.ts | 21 +- apps/sim/app/api/v1/logs/[id]/route.ts | 14 +- .../v1/logs/executions/[executionId]/route.ts | 4 +- apps/sim/app/api/v1/logs/filters.ts | 11 +- apps/sim/app/api/v1/logs/route.ts | 56 +- .../[id]/executions/[executionId]/route.ts | 21 +- .../components/trace-view/trace-view.tsx | 17 +- .../components/log-details/log-details.tsx | 139 +- .../logs/components/log-details/utils.ts | 7 +- .../workspace-notification-delivery.ts | 40 +- apps/sim/lib/api/contracts/logs.ts | 31 +- apps/sim/lib/api/contracts/subscription.ts | 1 - .../api/contracts/v1/admin/organizations.ts | 13 - apps/sim/lib/api/contracts/v1/admin/users.ts | 13 - apps/sim/lib/api/contracts/v1/logs.ts | 9 +- apps/sim/lib/billing/core/billing.ts | 47 +- apps/sim/lib/billing/core/organization.ts | 62 +- apps/sim/lib/billing/core/usage-log.ts | 154 +- apps/sim/lib/billing/core/usage.ts | 45 - .../lib/billing/credits/conversion.test.ts | 65 + apps/sim/lib/billing/credits/conversion.ts | 77 + apps/sim/lib/billing/index.ts | 1 - apps/sim/lib/billing/types/index.ts | 2 - .../lib/billing/validation/seat-management.ts | 28 +- apps/sim/lib/billing/webhooks/invoices.ts | 48 +- apps/sim/lib/copilot/chat/process-contents.ts | 18 +- .../server/workflow/get-execution-summary.ts | 52 +- .../server/workflow/get-workflow-logs.ts | 90 +- apps/sim/lib/core/utils/concurrency.ts | 35 + .../lib/data-drains/sources/workflow-logs.ts | 21 +- apps/sim/lib/execution/payloads/store.ts | 38 +- apps/sim/lib/logs/execution/logger.test.ts | 350 + apps/sim/lib/logs/execution/logger.ts | 539 +- .../logs/execution/logging-factory.test.ts | 97 +- .../sim/lib/logs/execution/logging-factory.ts | 80 +- .../logs/execution/logging-session.test.ts | 79 +- .../sim/lib/logs/execution/logging-session.ts | 232 +- apps/sim/lib/logs/execution/trace-store.ts | 179 + apps/sim/lib/logs/fetch-log-detail.ts | 76 +- apps/sim/lib/logs/filters.ts | 3 +- apps/sim/lib/table/service.ts | 16 +- apps/sim/lib/workspaces/organization/types.ts | 1 - apps/sim/providers/utils.ts | 8 +- apps/sim/scripts/backfill-trace-spans.ts | 188 + bun.lock | 1 - packages/db/migrations/0220_early_hellion.sql | 36 + .../db/migrations/meta/0220_snapshot.json | 17582 ++++++++++++++++ packages/db/migrations/meta/_journal.json | 7 + packages/db/schema.ts | 87 +- packages/db/scripts/migrate.ts | 29 + 61 files changed, 20003 insertions(+), 932 deletions(-) create mode 100644 apps/sim/lib/billing/credits/conversion.test.ts create mode 100644 apps/sim/lib/core/utils/concurrency.ts create mode 100644 apps/sim/lib/logs/execution/trace-store.ts create mode 100644 apps/sim/scripts/backfill-trace-spans.ts create mode 100644 packages/db/migrations/0220_early_hellion.sql create mode 100644 packages/db/migrations/meta/0220_snapshot.json diff --git a/apps/sim/app/api/billing/route.ts b/apps/sim/app/api/billing/route.ts index 1b9411647e..2cc338abd4 100644 --- a/apps/sim/app/api/billing/route.ts +++ b/apps/sim/app/api/billing/route.ts @@ -140,7 +140,6 @@ export const GET = withRouteHandler(async (request: NextRequest) => { members: rawBillingData.members.map((m) => ({ ...m, joinedAt: m.joinedAt.toISOString(), - lastActive: m.lastActive?.toISOString() || null, })), } diff --git a/apps/sim/app/api/knowledge/utils.test.ts b/apps/sim/app/api/knowledge/utils.test.ts index 326bbee660..b8297e4cf6 100644 --- a/apps/sim/app/api/knowledge/utils.test.ts +++ b/apps/sim/app/api/knowledge/utils.test.ts @@ -259,7 +259,12 @@ describe('Knowledge Utils', () => { {} ) - expect(dbOps.order).toEqual(['insert', 'updateDoc']) + // Embeddings are inserted first, then the document counter update. A + // usage_log billing insert (recordUsage) may trail after updateDoc and is + // irrelevant to this ordering invariant, so assert position rather than + // exact array equality. + expect(dbOps.order[0]).toBe('insert') + expect(dbOps.order.indexOf('updateDoc')).toBeGreaterThan(0) expect(dbOps.updatePayloads[0]).toMatchObject({ processingStatus: 'completed', diff --git a/apps/sim/app/api/logs/execution/[executionId]/route.ts b/apps/sim/app/api/logs/execution/[executionId]/route.ts index 71deb54267..adab287bf9 100644 --- a/apps/sim/app/api/logs/execution/[executionId]/route.ts +++ b/apps/sim/app/api/logs/execution/[executionId]/route.ts @@ -13,6 +13,7 @@ import { executionIdParamsSchema } from '@/lib/api/contracts/logs' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { materializeExecutionData } from '@/lib/logs/execution/trace-store' import type { TraceSpan, WorkflowExecutionLog } from '@/lib/logs/types' const logger = createLogger('LogsByExecutionIdAPI') @@ -39,13 +40,14 @@ export const GET = withRouteHandler( .select({ id: workflowExecutionLogs.id, workflowId: workflowExecutionLogs.workflowId, + workspaceId: workflowExecutionLogs.workspaceId, executionId: workflowExecutionLogs.executionId, stateSnapshotId: workflowExecutionLogs.stateSnapshotId, trigger: workflowExecutionLogs.trigger, startedAt: workflowExecutionLogs.startedAt, endedAt: workflowExecutionLogs.endedAt, totalDurationMs: workflowExecutionLogs.totalDurationMs, - cost: workflowExecutionLogs.cost, + costTotal: workflowExecutionLogs.costTotal, executionData: workflowExecutionLogs.executionData, }) .from(workflowExecutionLogs) @@ -119,7 +121,14 @@ export const GET = withRouteHandler( return NextResponse.json({ error: 'Workflow state snapshot not found' }, { status: 404 }) } - const executionData = workflowLog.executionData as WorkflowExecutionLog['executionData'] + const executionData = (await materializeExecutionData( + workflowLog.executionData as Record | null, + { + workspaceId: workflowLog.workspaceId, + workflowId: workflowLog.workflowId, + executionId: workflowLog.executionId, + } + )) as WorkflowExecutionLog['executionData'] const traceSpans = (executionData?.traceSpans as TraceSpan[]) || [] const childSnapshotIds = new Set() const collectSnapshotIds = (spans: TraceSpan[]) => { @@ -163,7 +172,7 @@ export const GET = withRouteHandler( startedAt: workflowLog.startedAt.toISOString(), endedAt: workflowLog.endedAt?.toISOString(), totalDurationMs: workflowLog.totalDurationMs, - cost: workflowLog.cost || null, + cost: workflowLog.costTotal != null ? { total: Number(workflowLog.costTotal) } : null, }, } diff --git a/apps/sim/app/api/logs/export/route.ts b/apps/sim/app/api/logs/export/route.ts index 2c817411b6..766435eadd 100644 --- a/apps/sim/app/api/logs/export/route.ts +++ b/apps/sim/app/api/logs/export/route.ts @@ -4,7 +4,9 @@ import { createLogger } from '@sim/logger' import { and, desc, eq, sql } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { getSession } from '@/lib/auth' +import { MATERIALIZE_CONCURRENCY, mapWithConcurrency } from '@/lib/core/utils/concurrency' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { materializeExecutionData } from '@/lib/logs/execution/trace-store' import { buildFilterConditions, LogFilterParamsSchema } from '@/lib/logs/filters' import { expandFolderIdsWithDescendants } from '@/lib/logs/folder-expansion' @@ -41,7 +43,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { startedAt: workflowExecutionLogs.startedAt, endedAt: workflowExecutionLogs.endedAt, totalDurationMs: workflowExecutionLogs.totalDurationMs, - cost: workflowExecutionLogs.cost, + costTotal: workflowExecutionLogs.costTotal, executionData: workflowExecutionLogs.executionData, workflowName: sql`COALESCE(${workflow.name}, 'Deleted Workflow')`, } @@ -96,11 +98,29 @@ export const GET = withRouteHandler(async (request: NextRequest) => { if (!rows.length) break - for (const r of rows as any[]) { + // Heavy execution data may live in object storage; materialize per + // row with bounded concurrency so a 1000-row page doesn't fan out + // into 1000 simultaneous reads. + const materialized = await mapWithConcurrency( + rows as any[], + MATERIALIZE_CONCURRENCY, + (r) => + materializeExecutionData(r.executionData as Record | null, { + workspaceId: params.workspaceId, + workflowId: r.workflowId, + executionId: r.executionId, + }) + ) + + for (let j = 0; j < rows.length; j++) { + const r = rows[j] as any + const ed = materialized[j] as Record + // A single malformed/unserializable row must not abort the whole CSV + // stream — derive the message/trace columns defensively and fall back + // to empty on error so the row's metadata still exports. let message = '' - let traces: any = null + let tracesJson = '' try { - const ed = (r as any).executionData if (ed) { if (ed.finalOutput) message = @@ -108,20 +128,25 @@ export const GET = withRouteHandler(async (request: NextRequest) => { ? ed.finalOutput : JSON.stringify(ed.finalOutput) if (ed.message) message = ed.message - if (ed.traceSpans) traces = ed.traceSpans + if (ed.traceSpans) tracesJson = JSON.stringify(ed.traceSpans) } - } catch {} + } catch (rowError) { + logger.warn('Skipping unserializable execution data for export row', { + executionId: r.executionId, + error: rowError instanceof Error ? rowError.message : String(rowError), + }) + } const line = [ escapeCsv(r.startedAt?.toISOString?.() || r.startedAt), escapeCsv(r.level), escapeCsv(r.workflowName), escapeCsv(r.trigger), escapeCsv(r.totalDurationMs ?? ''), - escapeCsv(r.cost?.total ?? r.cost?.value?.total ?? ''), + escapeCsv(r.costTotal ?? ''), escapeCsv(r.workflowId ?? ''), escapeCsv(r.executionId ?? ''), escapeCsv(message), - escapeCsv(traces ? JSON.stringify(traces) : ''), + escapeCsv(tracesJson), ].join(',') controller.enqueue(encoder.encode(`${line}\n`)) } diff --git a/apps/sim/app/api/logs/route.ts b/apps/sim/app/api/logs/route.ts index 89f52048b7..548383c103 100644 --- a/apps/sim/app/api/logs/route.ts +++ b/apps/sim/app/api/logs/route.ts @@ -31,6 +31,7 @@ import { listLogsContract, type WorkflowLogSummary } from '@/lib/api/contracts/l import { parseRequest } from '@/lib/api/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { jobCostTotal } from '@/lib/logs/fetch-log-detail' import { buildFilterConditions } from '@/lib/logs/filters' import { expandFolderIdsWithDescendants } from '@/lib/logs/folder-expansion' @@ -81,7 +82,8 @@ export const GET = withRouteHandler(async (request: NextRequest) => { case 'duration': return sql`${workflowExecutionLogs.totalDurationMs}` case 'cost': - return sql`(${workflowExecutionLogs.cost}->>'total')::numeric` + // Indexed projection of the usage_log ledger (dollars); no live aggregation. + return sql`${workflowExecutionLogs.costTotal}` case 'status': return sql`${workflowExecutionLogs.status}` default: @@ -201,7 +203,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { startedAt: workflowExecutionLogs.startedAt, endedAt: workflowExecutionLogs.endedAt, totalDurationMs: workflowExecutionLogs.totalDurationMs, - cost: workflowExecutionLogs.cost, + costTotal: workflowExecutionLogs.costTotal, createdAt: workflowExecutionLogs.createdAt, workflowName: workflow.name, workflowDescription: workflow.description, @@ -379,7 +381,9 @@ export const GET = withRouteHandler(async (request: NextRequest) => { } : null, jobTitle: null, - cost: (log.cost as WorkflowLogSummary['cost']) ?? null, + // List cost is the cost_total projection (faithful ledger sum). Null until + // completion (running) or until the one-time legacy backfill populates it. + cost: log.costTotal != null ? { total: Number(log.costTotal) } : null, pauseSummary: { status: log.pausedStatus ?? null, total: totalPauseCount, @@ -405,7 +409,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { createdAt: log.startedAt.toISOString(), workflow: null, jobTitle: log.jobTitle ?? null, - cost: (log.cost as WorkflowLogSummary['cost']) ?? null, + cost: jobCostTotal(log.cost), pauseSummary: { status: null, total: 0, resumed: 0 }, hasPendingPause: false, } diff --git a/apps/sim/app/api/organizations/[id]/members/[memberId]/route.ts b/apps/sim/app/api/organizations/[id]/members/[memberId]/route.ts index 89d2cc81cb..ed6bcf4d5b 100644 --- a/apps/sim/app/api/organizations/[id]/members/[memberId]/route.ts +++ b/apps/sim/app/api/organizations/[id]/members/[memberId]/route.ts @@ -11,6 +11,7 @@ import { import { parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { setActiveOrganizationForCurrentSession } from '@/lib/auth/active-organization' +import { getOrgMemberLedgerByUser } from '@/lib/billing/core/organization' import { getUserUsageData } from '@/lib/billing/core/usage' import { removeExternalUserFromOrganizationWorkspaces, @@ -101,10 +102,25 @@ export const GET = withRouteHandler( const computed = await getUserUsageData(memberId) if (usageData.length > 0) { + // currentPeriodCost is only a baseline; add this member's attributed + // usage_log for the period. (getUserUsageData returns the org POOL for + // org-scoped members, so it can't supply the per-member figure.) + const memberLedger = + ( + await getOrgMemberLedgerByUser( + organizationId, + computed.billingPeriodStart && computed.billingPeriodEnd + ? { start: computed.billingPeriodStart, end: computed.billingPeriodEnd } + : null + ) + ).get(memberId) ?? 0 memberData = { ...memberData, usage: { ...usageData[0], + currentPeriodCost: ( + Number(usageData[0].currentPeriodCost ?? 0) + memberLedger + ).toString(), billingPeriodStart: computed.billingPeriodStart, billingPeriodEnd: computed.billingPeriodEnd, }, diff --git a/apps/sim/app/api/organizations/[id]/members/route.ts b/apps/sim/app/api/organizations/[id]/members/route.ts index 48a356215c..e412a7e635 100644 --- a/apps/sim/app/api/organizations/[id]/members/route.ts +++ b/apps/sim/app/api/organizations/[id]/members/route.ts @@ -17,6 +17,7 @@ import { } from '@/lib/api/contracts/organization' import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' +import { getOrgMemberLedgerByUser } from '@/lib/billing/core/organization' import { ENTITLED_SUBSCRIPTION_STATUSES } from '@/lib/billing/subscriptions/utils' import { validateSeatAvailability } from '@/lib/billing/validation/seat-management' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -139,8 +140,21 @@ export const GET = withRouteHandler( const billingPeriodStart = orgSub?.periodStart ?? null const billingPeriodEnd = orgSub?.periodEnd ?? null + // currentPeriodCost is only a baseline; add each member's attributed + // usage_log for the period (batched, one query) so the roster shows real + // usage rather than the frozen baseline. + const usageByUser = await getOrgMemberLedgerByUser( + organizationId, + billingPeriodStart && billingPeriodEnd + ? { start: billingPeriodStart, end: billingPeriodEnd } + : null + ) + const membersWithUsage = base.map((row) => ({ ...row, + currentPeriodCost: ( + Number(row.currentPeriodCost ?? 0) + (usageByUser.get(row.userId) ?? 0) + ).toString(), billingPeriodStart, billingPeriodEnd, })) diff --git a/apps/sim/app/api/v1/admin/organizations/[id]/members/[memberId]/route.ts b/apps/sim/app/api/v1/admin/organizations/[id]/members/[memberId]/route.ts index 4dadf2cf93..7a33e11313 100644 --- a/apps/sim/app/api/v1/admin/organizations/[id]/members/[memberId]/route.ts +++ b/apps/sim/app/api/v1/admin/organizations/[id]/members/[memberId]/route.ts @@ -35,6 +35,7 @@ import { adminV1UpdateOrganizationMemberContract, } from '@/lib/api/contracts/v1/admin' import { parseRequest } from '@/lib/api/server' +import { getOrgMemberLedgerByUser } from '@/lib/billing/core/organization' import { removeUserFromOrganization } from '@/lib/billing/organizations/membership' import { isBillingEnabled } from '@/lib/core/config/feature-flags' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -86,7 +87,6 @@ export const GET = withRouteHandler( userEmail: user.email, currentPeriodCost: userStats.currentPeriodCost, currentUsageLimit: userStats.currentUsageLimit, - lastActive: userStats.lastActive, billingBlocked: userStats.billingBlocked, }) .from(member) @@ -99,6 +99,10 @@ export const GET = withRouteHandler( return notFoundResponse('Member') } + // currentPeriodCost is only a baseline; add this member's attributed + // usage_log for the org's period so admin shows real current usage. + const ledgerByUser = await getOrgMemberLedgerByUser(organizationId) + const data: AdminMemberDetail = { id: memberData.id, userId: memberData.userId, @@ -107,9 +111,10 @@ export const GET = withRouteHandler( createdAt: memberData.createdAt.toISOString(), userName: memberData.userName, userEmail: memberData.userEmail, - currentPeriodCost: memberData.currentPeriodCost ?? '0', + currentPeriodCost: ( + Number(memberData.currentPeriodCost ?? 0) + (ledgerByUser.get(memberData.userId) ?? 0) + ).toString(), currentUsageLimit: memberData.currentUsageLimit, - lastActive: memberData.lastActive?.toISOString() ?? null, billingBlocked: memberData.billingBlocked ?? false, } diff --git a/apps/sim/app/api/v1/admin/organizations/[id]/members/route.ts b/apps/sim/app/api/v1/admin/organizations/[id]/members/route.ts index 0c5b50fefd..5c4466d182 100644 --- a/apps/sim/app/api/v1/admin/organizations/[id]/members/route.ts +++ b/apps/sim/app/api/v1/admin/organizations/[id]/members/route.ts @@ -37,6 +37,7 @@ import { adminV1ListOrganizationMembersContract, } from '@/lib/api/contracts/v1/admin' import { parseRequest } from '@/lib/api/server' +import { getOrgMemberLedgerByUser } from '@/lib/billing/core/organization' import { addUserToOrganization } from '@/lib/billing/organizations/membership' import { isBillingEnabled } from '@/lib/core/config/feature-flags' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -96,7 +97,6 @@ export const GET = withRouteHandler( userEmail: user.email, currentPeriodCost: userStats.currentPeriodCost, currentUsageLimit: userStats.currentUsageLimit, - lastActive: userStats.lastActive, billingBlocked: userStats.billingBlocked, }) .from(member) @@ -109,6 +109,11 @@ export const GET = withRouteHandler( ]) const total = countResult[0].count + + // currentPeriodCost is only a baseline; add each member's attributed + // usage_log for the org's period so admin shows real current usage. + const usageByUser = await getOrgMemberLedgerByUser(organizationId) + const data: AdminMemberDetail[] = membersData.map((m) => ({ id: m.id, userId: m.userId, @@ -117,9 +122,10 @@ export const GET = withRouteHandler( createdAt: m.createdAt.toISOString(), userName: m.userName, userEmail: m.userEmail, - currentPeriodCost: m.currentPeriodCost ?? '0', + currentPeriodCost: ( + Number(m.currentPeriodCost ?? 0) + (usageByUser.get(m.userId) ?? 0) + ).toString(), currentUsageLimit: m.currentUsageLimit, - lastActive: m.lastActive?.toISOString() ?? null, billingBlocked: m.billingBlocked ?? false, })) diff --git a/apps/sim/app/api/v1/admin/organizations/[id]/seats/route.ts b/apps/sim/app/api/v1/admin/organizations/[id]/seats/route.ts index 6c01519040..921131e240 100644 --- a/apps/sim/app/api/v1/admin/organizations/[id]/seats/route.ts +++ b/apps/sim/app/api/v1/admin/organizations/[id]/seats/route.ts @@ -51,16 +51,6 @@ export const GET = withRouteHandler( subscriptionPlan: analytics.subscriptionPlan, canAddSeats: analytics.canAddSeats, utilizationRate: analytics.utilizationRate, - activeMembers: analytics.activeMembers, - inactiveMembers: analytics.inactiveMembers, - memberActivity: analytics.memberActivity.map((m) => ({ - userId: m.userId, - userName: m.userName, - userEmail: m.userEmail, - role: m.role, - joinedAt: m.joinedAt.toISOString(), - lastActive: m.lastActive?.toISOString() ?? null, - })), } logger.info(`Admin API: Retrieved seat analytics for organization ${organizationId}`) diff --git a/apps/sim/app/api/v1/admin/types.ts b/apps/sim/app/api/v1/admin/types.ts index 3cdbfc46d2..eb3ce167e0 100644 --- a/apps/sim/app/api/v1/admin/types.ts +++ b/apps/sim/app/api/v1/admin/types.ts @@ -543,7 +543,6 @@ export interface AdminMemberDetail extends AdminMember { // Billing/usage info from userStats currentPeriodCost: string currentUsageLimit: string | null - lastActive: string | null billingBlocked: boolean } @@ -574,28 +573,15 @@ interface AdminUserBilling { userEmail: string stripeCustomerId: string | null // Usage stats - totalManualExecutions: number - totalApiCalls: number - totalWebhookTriggers: number - totalScheduledExecutions: number - totalChatExecutions: number - totalMcpExecutions: number - totalA2aExecutions: number - totalTokensUsed: number - totalCost: string currentUsageLimit: string | null currentPeriodCost: string lastPeriodCost: string | null billedOverageThisPeriod: string storageUsedBytes: number - lastActive: string | null billingBlocked: boolean - // Copilot usage - totalCopilotCost: string + // Copilot usage (active per-period baselines) currentPeriodCopilotCost: string lastPeriodCopilotCost: string | null - totalCopilotTokens: number - totalCopilotCalls: number } export interface AdminUserBillingWithSubscription extends AdminUserBilling { @@ -643,16 +629,6 @@ export interface AdminSeatAnalytics { subscriptionPlan: string canAddSeats: boolean utilizationRate: number - activeMembers: number - inactiveMembers: number - memberActivity: Array<{ - userId: string - userName: string - userEmail: string - role: string - joinedAt: string - lastActive: string | null - }> } export interface AdminDeploymentVersion { diff --git a/apps/sim/app/api/v1/admin/users/[id]/billing/route.ts b/apps/sim/app/api/v1/admin/users/[id]/billing/route.ts index 7c080a4e6e..85a5f69b63 100644 --- a/apps/sim/app/api/v1/admin/users/[id]/billing/route.ts +++ b/apps/sim/app/api/v1/admin/users/[id]/billing/route.ts @@ -29,6 +29,7 @@ import { } from '@/lib/api/contracts/v1/admin' import { parseRequest } from '@/lib/api/server' import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription' +import { getUserUsageData } from '@/lib/billing/core/usage' import { isOrgScopedSubscription } from '@/lib/billing/subscriptions/utils' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { withAdminAuthParams } from '@/app/api/v1/admin/middleware' @@ -78,6 +79,11 @@ export const GET = withRouteHandler( const [stats] = await db.select().from(userStats).where(eq(userStats.userId, userId)).limit(1) + // currentPeriodCost is now only a baseline; canonical current-period usage + // (baseline + attributed usage_log, refresh-adjusted) comes from the same + // helper users see, so admin reflects real usage instead of a stale 0. + const usage = await getUserUsageData(userId) + const memberOrgs = await db .select({ organizationId: member.organizationId, @@ -107,27 +113,14 @@ export const GET = withRouteHandler( userName: userData.name, userEmail: userData.email, stripeCustomerId: userData.stripeCustomerId, - totalManualExecutions: stats?.totalManualExecutions ?? 0, - totalApiCalls: stats?.totalApiCalls ?? 0, - totalWebhookTriggers: stats?.totalWebhookTriggers ?? 0, - totalScheduledExecutions: stats?.totalScheduledExecutions ?? 0, - totalChatExecutions: stats?.totalChatExecutions ?? 0, - totalMcpExecutions: stats?.totalMcpExecutions ?? 0, - totalA2aExecutions: stats?.totalA2aExecutions ?? 0, - totalTokensUsed: stats?.totalTokensUsed ?? 0, - totalCost: stats?.totalCost ?? '0', currentUsageLimit: stats?.currentUsageLimit ?? null, - currentPeriodCost: stats?.currentPeriodCost ?? '0', + currentPeriodCost: usage.currentUsage.toString(), lastPeriodCost: stats?.lastPeriodCost ?? null, billedOverageThisPeriod: stats?.billedOverageThisPeriod ?? '0', storageUsedBytes: stats?.storageUsedBytes ?? 0, - lastActive: stats?.lastActive?.toISOString() ?? null, billingBlocked: stats?.billingBlocked ?? false, - totalCopilotCost: stats?.totalCopilotCost ?? '0', currentPeriodCopilotCost: stats?.currentPeriodCopilotCost ?? '0', lastPeriodCopilotCost: stats?.lastPeriodCopilotCost ?? null, - totalCopilotTokens: stats?.totalCopilotTokens ?? 0, - totalCopilotCalls: stats?.totalCopilotCalls ?? 0, subscriptions: subscriptions.map(toAdminSubscription), organizationMemberships: memberOrgs.map((m) => ({ organizationId: m.organizationId, diff --git a/apps/sim/app/api/v1/logs/[id]/route.ts b/apps/sim/app/api/v1/logs/[id]/route.ts index c32acfd444..9858a1b4e2 100644 --- a/apps/sim/app/api/v1/logs/[id]/route.ts +++ b/apps/sim/app/api/v1/logs/[id]/route.ts @@ -7,6 +7,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { v1GetLogContract } from '@/lib/api/contracts/v1/logs' import { parseRequest } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { materializeExecutionData } from '@/lib/logs/execution/trace-store' import { createApiResponse, getUserLimits } from '@/app/api/v1/logs/meta' import { checkRateLimit, @@ -50,7 +51,7 @@ export const GET = withRouteHandler( endedAt: workflowExecutionLogs.endedAt, totalDurationMs: workflowExecutionLogs.totalDurationMs, executionData: workflowExecutionLogs.executionData, - cost: workflowExecutionLogs.cost, + costTotal: workflowExecutionLogs.costTotal, files: workflowExecutionLogs.files, createdAt: workflowExecutionLogs.createdAt, workflowName: workflow.name, @@ -101,8 +102,15 @@ export const GET = withRouteHandler( totalDurationMs: log.totalDurationMs, files: log.files || undefined, workflow: workflowSummary, - executionData: log.executionData as any, - cost: log.cost as any, + executionData: (await materializeExecutionData( + log.executionData as Record | null, + { + workspaceId: log.workspaceId, + workflowId: log.workflowId, + executionId: log.executionId, + } + )) as any, + cost: log.costTotal != null ? { total: Number(log.costTotal) } : null, createdAt: log.createdAt.toISOString(), } diff --git a/apps/sim/app/api/v1/logs/executions/[executionId]/route.ts b/apps/sim/app/api/v1/logs/executions/[executionId]/route.ts index e7503ecb07..eefad39bb8 100644 --- a/apps/sim/app/api/v1/logs/executions/[executionId]/route.ts +++ b/apps/sim/app/api/v1/logs/executions/[executionId]/route.ts @@ -70,7 +70,9 @@ export const GET = withRouteHandler( startedAt: workflowLog.startedAt.toISOString(), endedAt: workflowLog.endedAt?.toISOString(), totalDurationMs: workflowLog.totalDurationMs, - cost: workflowLog.cost || null, + // Sourced from the cost_total projection of the usage_log ledger + // (the deprecated cost jsonb column was dropped). + cost: workflowLog.costTotal != null ? { total: Number(workflowLog.costTotal) } : null, }, } diff --git a/apps/sim/app/api/v1/logs/filters.ts b/apps/sim/app/api/v1/logs/filters.ts index 4f9bdbdfc6..0e409e4d53 100644 --- a/apps/sim/app/api/v1/logs/filters.ts +++ b/apps/sim/app/api/v1/logs/filters.ts @@ -84,18 +84,19 @@ export function buildLogFilters(filters: LogFilters): SQL { conditions.push(lte(workflowExecutionLogs.totalDurationMs, filters.maxDurationMs)) } - // Cost filters + // Cost filters — indexed projection of the usage_log ledger (dollars). if (filters.minCost !== undefined) { - conditions.push(sql`(${workflowExecutionLogs.cost}->>'total')::numeric >= ${filters.minCost}`) + conditions.push(sql`${workflowExecutionLogs.costTotal} >= ${filters.minCost}`) } if (filters.maxCost !== undefined) { - conditions.push(sql`(${workflowExecutionLogs.cost}->>'total')::numeric <= ${filters.maxCost}`) + conditions.push(sql`${workflowExecutionLogs.costTotal} <= ${filters.maxCost}`) } - // Model filter + // Model filter — uses the models_used projection (includes zero-cost/BYOK + // models, which the usage_log ledger drops), preserving prior behavior. if (filters.model) { - conditions.push(sql`${workflowExecutionLogs.cost}->>'models' ? ${filters.model}`) + conditions.push(sql`${workflowExecutionLogs.modelsUsed} @> ARRAY[${filters.model}]::text[]`) } // Combine all conditions with AND diff --git a/apps/sim/app/api/v1/logs/route.ts b/apps/sim/app/api/v1/logs/route.ts index 0f8f7b31b8..c85031e3cc 100644 --- a/apps/sim/app/api/v1/logs/route.ts +++ b/apps/sim/app/api/v1/logs/route.ts @@ -6,7 +6,9 @@ import { and, eq, sql } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { v1ListLogsContract } from '@/lib/api/contracts/v1/logs' import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' +import { MATERIALIZE_CONCURRENCY, mapWithConcurrency } from '@/lib/core/utils/concurrency' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { materializeExecutionData } from '@/lib/logs/execution/trace-store' import { buildLogFilters, getOrderBy } from '@/app/api/v1/logs/filters' import { createApiResponse, getUserLimits } from '@/app/api/v1/logs/meta' import { @@ -103,6 +105,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { .select({ id: workflowExecutionLogs.id, workflowId: workflowExecutionLogs.workflowId, + workspaceId: workflowExecutionLogs.workspaceId, executionId: workflowExecutionLogs.executionId, deploymentVersionId: workflowExecutionLogs.deploymentVersionId, level: workflowExecutionLogs.level, @@ -110,7 +113,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { startedAt: workflowExecutionLogs.startedAt, endedAt: workflowExecutionLogs.endedAt, totalDurationMs: workflowExecutionLogs.totalDurationMs, - cost: workflowExecutionLogs.cost, + costTotal: workflowExecutionLogs.costTotal, files: workflowExecutionLogs.files, executionData: params.details === 'full' ? workflowExecutionLogs.executionData : sql`null`, workflowName: workflow.name, @@ -144,7 +147,12 @@ export const GET = withRouteHandler(async (request: NextRequest) => { }) } - const formattedLogs = data.map((log) => { + // Only materialize externalized execution data when the response actually + // needs it (details=full + finalOutput/traceSpans requested). + const needsMaterialize = + params.details === 'full' && (params.includeFinalOutput || params.includeTraceSpans) + + const buildBase = (log: (typeof data)[number]) => { const result: any = { id: log.id, workflowId: log.workflowId, @@ -155,7 +163,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { startedAt: log.startedAt.toISOString(), endedAt: log.endedAt?.toISOString() || null, totalDurationMs: log.totalDurationMs, - cost: log.cost ? { total: (log.cost as any).total } : null, + cost: log.costTotal != null ? { total: Number(log.costTotal) } : null, files: log.files || null, } @@ -166,24 +174,36 @@ export const GET = withRouteHandler(async (request: NextRequest) => { description: log.workflowDescription, deleted: !log.workflowName, } - - if (log.cost) { - result.cost = log.cost - } - - if (log.executionData) { - const execData = log.executionData as any - if (params.includeFinalOutput && execData.finalOutput) { - result.finalOutput = execData.finalOutput - } - if (params.includeTraceSpans && execData.traceSpans) { - result.traceSpans = execData.traceSpans - } - } } return result - }) + } + + // Only run the bounded-concurrency materialization when the response actually + // needs object-storage reads; otherwise a plain synchronous map avoids the + // per-row worker/promise overhead. + const formattedLogs = needsMaterialize + ? await mapWithConcurrency(data, MATERIALIZE_CONCURRENCY, async (log) => { + const result = buildBase(log) + if (log.executionData) { + const execData = (await materializeExecutionData( + log.executionData as Record | null, + { + workspaceId: log.workspaceId, + workflowId: log.workflowId, + executionId: log.executionId, + } + )) as any + if (params.includeFinalOutput && execData.finalOutput) { + result.finalOutput = execData.finalOutput + } + if (params.includeTraceSpans && execData.traceSpans) { + result.traceSpans = execData.traceSpans + } + } + return result + }) + : data.map(buildBase) const limits = await getUserLimits(userId) diff --git a/apps/sim/app/api/workflows/[id]/executions/[executionId]/route.ts b/apps/sim/app/api/workflows/[id]/executions/[executionId]/route.ts index 6efff82a7c..fbbdb0abb5 100644 --- a/apps/sim/app/api/workflows/[id]/executions/[executionId]/route.ts +++ b/apps/sim/app/api/workflows/[id]/executions/[executionId]/route.ts @@ -9,6 +9,7 @@ import { } from '@/lib/api/contracts/workflows' import { parseRequest } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { materializeExecutionData } from '@/lib/logs/execution/trace-store' import { validateWorkflowAccess } from '@/app/api/workflows/middleware' import type { PausePoint } from '@/executor/types' @@ -117,6 +118,7 @@ export const GET = withRouteHandler( .select({ executionId: workflowExecutionLogs.executionId, workflowId: workflowExecutionLogs.workflowId, + workspaceId: workflowExecutionLogs.workspaceId, status: workflowExecutionLogs.status, level: workflowExecutionLogs.level, trigger: workflowExecutionLogs.trigger, @@ -124,7 +126,7 @@ export const GET = withRouteHandler( endedAt: workflowExecutionLogs.endedAt, totalDurationMs: workflowExecutionLogs.totalDurationMs, executionData: workflowExecutionLogs.executionData, - cost: workflowExecutionLogs.cost, + costTotal: workflowExecutionLogs.costTotal, }) .from(workflowExecutionLogs) .where( @@ -177,13 +179,20 @@ export const GET = withRouteHandler( } } - const cost = logRow.cost - ? { total: Number((logRow.cost as { total?: number }).total ?? 0) } - : null + const cost = logRow.costTotal != null ? { total: Number(logRow.costTotal) } : null - const error = status === 'failed' ? extractError(logRow.executionData) : null + // Heavy execution data may live in object storage; resolve the pointer + // before reading error / finalOutput / traceSpans (no-op for inline rows). + const executionData = (await materializeExecutionData( + logRow.executionData as Record | null, + { + workspaceId: logRow.workspaceId, + workflowId: logRow.workflowId, + executionId: logRow.executionId, + } + )) as ExecutionDataShape | undefined - const executionData = logRow.executionData as ExecutionDataShape | undefined + const error = status === 'failed' ? extractError(executionData) : null const finalOutput = includeOutput && status === 'completed' && executionData diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/trace-view/trace-view.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/trace-view/trace-view.tsx index 07a3cf7818..5a170bb948 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/trace-view/trace-view.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/trace-view/trace-view.tsx @@ -55,6 +55,13 @@ const MIN_BAR_PCT = 0.5 interface TraceViewProps { traceSpans: TraceSpan[] + /** + * Authoritative, multiplier-inclusive run cost (dollars) from the persisted + * execution log. When provided it drives the header credit chip so the Trace + * tab and the Overview cost breakdown can never show different totals. Falls + * back to the root span's own cost only when absent (e.g. live previews). + */ + runCostDollars?: number } interface FlatSpanEntry { @@ -708,8 +715,10 @@ const TraceDetailPane = memo(function TraceDetailPane({ span }: { span: TraceSpa if (cacheRead) metaEntries.push({ label: 'Cache read', value: cacheRead }) if (cacheWrite) metaEntries.push({ label: 'Cache write', value: cacheWrite }) if (reasoning) metaEntries.push({ label: 'Reasoning tokens', value: reasoning }) - const costTotal = formatCostAmount(span.cost?.total) - if (costTotal) metaEntries.push({ label: 'Cost', value: costTotal }) + // Per-span cost is intentionally not shown: cost lives only in the usage_log + // ledger (the authoritative, multiplier-inclusive run total drives the header + // chip). Persisted spans are cost-stripped, so a per-span row would render on + // live runs but vanish on reload — show one consistent total instead. if (span.errorType) metaEntries.push({ label: 'Error type', value: span.errorType }) if (span.iterationIndex !== undefined) metaEntries.push({ label: 'Iteration', value: String(span.iterationIndex + 1) }) @@ -812,7 +821,7 @@ const TraceDetailPane = memo(function TraceDetailPane({ span }: { span: TraceSpa * in a way that mirrors the executor's internal structure so investigators can * follow block-by-block and segment-by-segment what happened and why. */ -export const TraceView = memo(function TraceView({ traceSpans }: TraceViewProps) { +export const TraceView = memo(function TraceView({ traceSpans, runCostDollars }: TraceViewProps) { const treeRef = useRef(null) const [searchQuery, setSearchQuery] = useState('') const [treePaneWidth, setTreePaneWidth] = useState(DEFAULT_TREE_PANE_WIDTH) @@ -1021,7 +1030,7 @@ export const TraceView = memo(function TraceView({ traceSpans }: TraceViewProps) {blockCount} {blockCount === 1 ? 'span' : 'spans'} {(() => { - const rootCost = formatCostAmount(normalizedSpans[0]?.cost?.total) + const rootCost = formatCostAmount(runCostDollars ?? normalizedSpans[0]?.cost?.total) return rootCost ? ( {rootCost} diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx index a4bf55ea76..615b885a0c 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx @@ -24,6 +24,7 @@ import { } from '@/components/emcn' import type { WorkflowLogRow } from '@/lib/api/contracts/logs' import { BASE_EXECUTION_CHARGE } from '@/lib/billing/constants' +import { apportionCredits, dollarsToCredits } from '@/lib/billing/credits/conversion' import { cn } from '@/lib/core/utils/cn' import { handleKeyboardActivation } from '@/lib/core/utils/keyboard' import { filterHiddenOutputKeys } from '@/lib/logs/execution/trace-spans/trace-spans' @@ -49,6 +50,16 @@ import { formatCost } from '@/providers/utils' import { useLogDetailsUIStore } from '@/stores/logs/store' import { MAX_LOG_DETAILS_WIDTH_RATIO, MIN_LOG_DETAILS_WIDTH } from '@/stores/logs/utils' +/** + * Renders an already-apportioned integer credit value. `dollars` is only used + * to distinguish a genuine zero ("0 credits") from a sub-credit charge that + * rounded down to zero ("<1 credit"); the credit figure itself is authoritative. + */ +function creditLabel(credits: number, dollars: number): string { + if (credits <= 0) return dollars > 0 ? '<1 credit' : '0 credits' + return `${credits.toLocaleString()} ${credits === 1 ? 'credit' : 'credits'}` +} + export const WorkflowOutputSection = memo( function WorkflowOutputSection({ output }: { output: Record }) { const contentRef = useRef(null) @@ -326,6 +337,54 @@ export function LogDetailsContent({ log, onActiveTabChange }: LogDetailsContentP return { input: raw } as Record }, [log.executionData]) + // Cost breakdown, sourced solely from the usage_log ledger (single source of + // truth). Line items (Base Run / per-model / per-integration) get integer + // credits apportioned with a single round at the total so rows always + // reconcile (never round-then-sum, which drifts). Pre-ledger runs that only + // have the cost_total projection show the total alone — no itemization, no + // parallel jsonb reconstruction. + const costBreakdown = useMemo((): { + rows: Array<{ key: string; label: string; credits: number; dollars: number }> + totalCredits: number + totalDollars: number + tokens: { input: number; output: number } + } | null => { + const ledger = log.costLedger + if (ledger && ledger.items.length > 0) { + const credits = apportionCredits( + ledger.items.map((item, i) => ({ key: String(i), dollars: item.cost })) + ) + const rows = ledger.items.map((item, i) => ({ + key: String(i), + label: + item.category === 'fixed' && item.description === 'execution_fee' + ? 'Base Run' + : item.description, + credits: credits[String(i)] ?? 0, + dollars: item.cost, + })) + return { + rows, + totalCredits: dollarsToCredits(ledger.total), + totalDollars: ledger.total, + tokens: { + input: ledger.items.reduce((s, it) => s + (it.inputTokens ?? 0), 0), + output: ledger.items.reduce((s, it) => s + (it.outputTokens ?? 0), 0), + }, + } + } + + // Total-only (pre-ledger runs with just the cost_total projection). + const total = log.cost?.total + if (total == null) return null + return { + rows: [], + totalCredits: dollarsToCredits(total), + totalDollars: total, + tokens: { input: 0, output: 0 }, + } + }, [log.costLedger, log.cost]) + const formattedTimestamp = formatDate(log.createdAt) const logStatus = getDisplayStatus(log.status) @@ -529,67 +588,39 @@ export function LogDetailsContent({ log, onActiveTabChange }: LogDetailsContentP {log.files && log.files.length > 0 && } {/* Cost Breakdown */} - {hasCostInfo && ( + {hasCostInfo && costBreakdown && (
-
- - Base Run - - - {formatCost(BASE_EXECUTION_CHARGE)} - -
-
- - Model Input - - - {formatCost(log.cost?.input || 0)} - -
-
- - Model Output - - - {formatCost(log.cost?.output || 0)} - -
- {(() => { - const models = (log.cost as Record)?.models as - | Record - | undefined - const totalToolCost = models - ? Object.values(models).reduce((sum, m) => sum + (m?.toolCost || 0), 0) - : 0 - return totalToolCost > 0 ? ( -
- - Tool Usage - - - {formatCost(totalToolCost)} - -
- ) : null - })()} + {costBreakdown.rows.map((row) => ( +
+ + {row.label} + + + {creditLabel(row.credits, row.dollars)} + +
+ ))}
Total - {formatCost(log.cost?.total || 0)} - -
-
- - Tokens - - - {log.cost?.tokens?.input || log.cost?.tokens?.prompt || 0} in ·{' '} - {log.cost?.tokens?.output || log.cost?.tokens?.completion || 0} out + {creditLabel(costBreakdown.totalCredits, costBreakdown.totalDollars)}
+ {(costBreakdown.tokens.input > 0 || costBreakdown.tokens.output > 0) && ( +
+ + Tokens + + + {costBreakdown.tokens.input} in · {costBreakdown.tokens.output} out + +
+ )}

Total includes a {formatCost(BASE_EXECUTION_CHARGE)} base charge plus model and @@ -608,7 +639,7 @@ export function LogDetailsContent({ log, onActiveTabChange }: LogDetailsContentP className='mt-3 min-h-0 flex-1 overflow-hidden focus-visible:outline-none' > {traceSpans?.length ? ( - + ) : log.executionData ? (

diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/utils.ts b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/utils.ts index 4f142b0c67..09ead88ac8 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/utils.ts +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/utils.ts @@ -1,6 +1,6 @@ import type React from 'react' import { AgentSkillsIcon, WorkflowIcon } from '@/components/icons' -import { dollarsToCredits } from '@/lib/billing/credits/conversion' +import { formatCreditCost } from '@/lib/billing/credits/conversion' import type { TraceSpan } from '@/lib/logs/types' import { LoopTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/loop/loop-config' import { ParallelTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/parallel/parallel-config' @@ -117,10 +117,7 @@ export function getDisplayName(span: TraceSpan): string { } export function formatCostAmount(value: number | undefined): string | undefined { - if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return undefined - const credits = dollarsToCredits(value) - if (credits <= 0) return '<1 credit' - return `${credits.toLocaleString('en-US')} ${credits === 1 ? 'credit' : 'credits'}` + return formatCreditCost(value, { emptyForZeroOrLess: true }) } export function formatTokensSummary(tokens: TraceSpan['tokens']): string | undefined { diff --git a/apps/sim/background/workspace-notification-delivery.ts b/apps/sim/background/workspace-notification-delivery.ts index 1baaa3b20b..76c2333d9f 100644 --- a/apps/sim/background/workspace-notification-delivery.ts +++ b/apps/sim/background/workspace-notification-delivery.ts @@ -26,6 +26,7 @@ import { RateLimiter } from '@/lib/core/rate-limiter' import { decryptSecret } from '@/lib/core/security/encryption' import { secureFetchWithValidation } from '@/lib/core/security/input-validation.server' import { getBaseUrl } from '@/lib/core/utils/urls' +import { materializeExecutionData } from '@/lib/logs/execution/trace-store' import type { TraceSpan, WorkflowExecutionLog } from '@/lib/logs/types' import { sendEmail } from '@/lib/messaging/email/mailer' import type { AlertConfig } from '@/lib/notifications/alert-rules' @@ -507,27 +508,6 @@ function formatLogDate(value: Date | string | null | undefined, fallback = ''): return typeof value === 'string' ? value : fallback } -function normalizeLogCost(value: unknown): WorkflowExecutionLog['cost'] { - if (!isRecord(value)) { - return undefined - } - - const tokens = isRecord(value.tokens) - ? { - input: typeof value.tokens.input === 'number' ? value.tokens.input : undefined, - output: typeof value.tokens.output === 'number' ? value.tokens.output : undefined, - total: typeof value.tokens.total === 'number' ? value.tokens.total : undefined, - } - : undefined - - return { - input: typeof value.input === 'number' ? value.input : undefined, - output: typeof value.output === 'number' ? value.output : undefined, - total: typeof value.total === 'number' ? value.total : undefined, - tokens, - } -} - function normalizeLogFiles(value: unknown): WorkflowExecutionLog['files'] { if (!Array.isArray(value)) { return undefined @@ -545,11 +525,18 @@ function normalizeLogFiles(value: unknown): WorkflowExecutionLog['files'] { ) } -function normalizeWorkflowExecutionLog( +async function normalizeWorkflowExecutionLog( row: typeof workflowExecutionLogs.$inferSelect -): WorkflowExecutionLog { +): Promise { const startedAt = formatLogDate(row.startedAt) + // Heavy execution data may live in object storage; resolve the pointer so + // retry deliveries get finalOutput/traceSpans (no-op for inline rows). + const executionData = await materializeExecutionData( + isRecord(row.executionData) ? row.executionData : {}, + { workspaceId: row.workspaceId, workflowId: row.workflowId, executionId: row.executionId } + ) + return { id: row.id, workflowId: row.workflowId, @@ -561,8 +548,9 @@ function normalizeWorkflowExecutionLog( endedAt: formatLogDate(row.endedAt, startedAt), totalDurationMs: row.totalDurationMs ?? 0, files: normalizeLogFiles(row.files), - executionData: isRecord(row.executionData) ? row.executionData : {}, - cost: normalizeLogCost(row.cost), + executionData: executionData as WorkflowExecutionLog['executionData'], + // cost_total projection of the usage_log ledger (not the deprecated jsonb). + cost: row.costTotal != null ? { total: Number(row.costTotal) } : undefined, createdAt: formatLogDate(row.createdAt, startedAt), } } @@ -580,7 +568,7 @@ async function buildRetryLog(params: NotificationDeliveryParams): Promise +export type CostLedgerItem = z.output + const pauseSummarySchema = z.object({ status: z.string().nullable(), total: z.number(), @@ -218,7 +239,10 @@ export const workflowLogSummarySchema = z.object({ createdAt: z.string(), workflow: workflowSummarySchema.nullable(), jobTitle: z.string().nullable(), - cost: costSummarySchema.nullable(), + // Top-level run cost is the cost_total projection of the usage_log ledger, + // rendered as { total } (dollars). The itemized breakdown lives in costLedger + // (detail only); per-block costs use the richer costSummarySchema elsewhere. + cost: z.object({ total: z.number() }).nullable(), pauseSummary: pauseSummarySchema, hasPendingPause: z.boolean(), }) @@ -226,6 +250,9 @@ export const workflowLogSummarySchema = z.object({ export const workflowLogDetailSchema = workflowLogSummarySchema.extend({ executionData: executionDataDetailSchema, files: z.array(userFileSchema).nullable(), + // Itemized, ledger-sourced cost breakdown. Null for legacy/pre-ledger runs, + // where the UI falls back to the (reconciling) cost jsonb. + costLedger: costLedgerSchema.nullable().optional(), }) export type WorkflowLogSummary = z.output @@ -236,7 +263,7 @@ export type WorkflowLogDetail = z.output * UI surfaces that render the same log before and after its detail query resolves. */ export type WorkflowLogRow = WorkflowLogSummary & - Partial> + Partial> export const listLogsResponseSchema = z.object({ data: z.array(workflowLogSummarySchema), diff --git a/apps/sim/lib/api/contracts/subscription.ts b/apps/sim/lib/api/contracts/subscription.ts index c8b486deaf..ee1498ddac 100644 --- a/apps/sim/lib/api/contracts/subscription.ts +++ b/apps/sim/lib/api/contracts/subscription.ts @@ -103,7 +103,6 @@ export const organizationBillingMemberSchema = z userName: z.string().nullable().optional(), userEmail: z.string().nullable().optional(), joinedAt: z.string().nullable().optional(), - lastActive: z.string().nullable().optional(), }) .passthrough() diff --git a/apps/sim/lib/api/contracts/v1/admin/organizations.ts b/apps/sim/lib/api/contracts/v1/admin/organizations.ts index 9dad65f197..1281b5e649 100644 --- a/apps/sim/lib/api/contracts/v1/admin/organizations.ts +++ b/apps/sim/lib/api/contracts/v1/admin/organizations.ts @@ -43,7 +43,6 @@ export const adminV1MemberSchema = z.object({ export const adminV1MemberDetailSchema = adminV1MemberSchema.extend({ currentPeriodCost: z.string(), currentUsageLimit: z.string().nullable(), - lastActive: z.string().nullable(), billingBlocked: z.boolean(), }) @@ -75,18 +74,6 @@ export const adminV1SeatAnalyticsSchema = z.object({ subscriptionPlan: z.string(), canAddSeats: z.boolean(), utilizationRate: z.number(), - activeMembers: z.number(), - inactiveMembers: z.number(), - memberActivity: z.array( - z.object({ - userId: z.string(), - userName: z.string(), - userEmail: z.string(), - role: z.string(), - joinedAt: z.string(), - lastActive: z.string().nullable(), - }) - ), }) export const adminV1CreateOrganizationBodySchema = z.object({ diff --git a/apps/sim/lib/api/contracts/v1/admin/users.ts b/apps/sim/lib/api/contracts/v1/admin/users.ts index 6610134d93..34bc00f5e2 100644 --- a/apps/sim/lib/api/contracts/v1/admin/users.ts +++ b/apps/sim/lib/api/contracts/v1/admin/users.ts @@ -23,27 +23,14 @@ export const adminV1UserBillingSchema = z.object({ userName: z.string(), userEmail: z.string(), stripeCustomerId: z.string().nullable(), - totalManualExecutions: z.number(), - totalApiCalls: z.number(), - totalWebhookTriggers: z.number(), - totalScheduledExecutions: z.number(), - totalChatExecutions: z.number(), - totalMcpExecutions: z.number(), - totalA2aExecutions: z.number(), - totalTokensUsed: z.number(), - totalCost: z.string(), currentUsageLimit: z.string().nullable(), currentPeriodCost: z.string(), lastPeriodCost: z.string().nullable(), billedOverageThisPeriod: z.string(), storageUsedBytes: z.number(), - lastActive: z.string().nullable(), billingBlocked: z.boolean(), - totalCopilotCost: z.string(), currentPeriodCopilotCost: z.string(), lastPeriodCopilotCost: z.string().nullable(), - totalCopilotTokens: z.number(), - totalCopilotCalls: z.number(), }) export const adminV1UserBillingWithSubscriptionSchema = adminV1UserBillingSchema.extend({ diff --git a/apps/sim/lib/api/contracts/v1/logs.ts b/apps/sim/lib/api/contracts/v1/logs.ts index 6659ccb99a..52e48b096d 100644 --- a/apps/sim/lib/api/contracts/v1/logs.ts +++ b/apps/sim/lib/api/contracts/v1/logs.ts @@ -27,7 +27,14 @@ export const v1ListLogsQuerySchema = z.object({ details: z.enum(['basic', 'full']).optional().default('basic'), includeTraceSpans: booleanQueryFlagSchema.optional().default(false), includeFinalOutput: booleanQueryFlagSchema.optional().default(false), - limit: z.coerce.number().optional().default(100), + // Clamp rather than reject: this limit was previously unbounded, so a hard + // .max() would 400 existing consumers passing a larger value. Page size is + // still capped to bound response size and materialization reads. + limit: z.coerce + .number() + .optional() + .default(100) + .transform((v) => Math.min(Math.max(1, Math.trunc(v)), 1000)), cursor: z.string().optional(), order: z.enum(['desc', 'asc']).optional().default('desc'), }) diff --git a/apps/sim/lib/billing/core/billing.ts b/apps/sim/lib/billing/core/billing.ts index 8595277c38..2db6b3ef7c 100644 --- a/apps/sim/lib/billing/core/billing.ts +++ b/apps/sim/lib/billing/core/billing.ts @@ -7,7 +7,7 @@ import { type SubscriptionMetadata, } from '@/lib/billing/core/subscription' import { getOrgUsageLimit, getUserUsageData } from '@/lib/billing/core/usage' -import { getBillingPeriodUsageCost } from '@/lib/billing/core/usage-log' +import { COPILOT_USAGE_SOURCES, getBillingPeriodUsageCost } from '@/lib/billing/core/usage-log' import { getCreditBalance } from '@/lib/billing/credits/balance' import { computeDailyRefreshConsumed, @@ -128,6 +128,8 @@ async function aggregateOrgMemberStats(organizationId: string): Promise<{ .where(eq(member.organizationId, organizationId)) let currentPeriodCost = new Decimal(0) + // Copilot baseline (copilot source). All copilot-family usage (incl. MCP) lives + // in usage_log and is added via the copilot ledger by callers — not a baseline. let currentPeriodCopilotCost = new Decimal(0) let lastPeriodCopilotCost = new Decimal(0) const memberIds: string[] = [] @@ -454,20 +456,34 @@ export async function getSimplifiedBillingSummary( const pooled = await aggregateOrgMemberStats(organizationId) const rawCurrentUsage = pooled.currentPeriodCost - const totalCopilotCost = pooled.currentPeriodCopilotCost const totalLastPeriodCopilotCost = pooled.lastPeriodCopilotCost // Deduct daily-refresh credits against this specific org's pool. // `usageData` is derived from the caller's priority subscription // and may not match the requested org (multi-org admins, personal // priority sub, etc.), so it cannot be reused here. - const ledgerUsage = + const orgBillingPeriod = subscription.periodStart && subscription.periodEnd + ? { start: subscription.periodStart, end: subscription.periodEnd } + : null + const ledgerUsage = orgBillingPeriod + ? await getBillingPeriodUsageCost( + { type: 'organization', id: organizationId }, + orgBillingPeriod + ) + : 0 + // Copilot breakdown = member baselines (copilot + MCP) + the copilot-family + // ledger for the period (COPILOT_USAGE_SOURCES: copilot/workspace-chat/ + // mcp_copilot/mothership_block); the baseline columns are no longer incremented. + const totalCopilotCost = + pooled.currentPeriodCopilotCost + + (orgBillingPeriod ? await getBillingPeriodUsageCost( { type: 'organization', id: organizationId }, - { start: subscription.periodStart, end: subscription.periodEnd } + orgBillingPeriod, + COPILOT_USAGE_SOURCES ) - : 0 + : 0) let refreshDeduction = 0 if (isPaid(plan) && subscription.periodStart) { const planDollars = getPlanTierDollars(plan) @@ -561,6 +577,8 @@ export async function getSimplifiedBillingSummary( .where(eq(userStats.userId, userId)) .limit(1) + // Copilot baseline (copilot source). MCP copilot usage lives in usage_log and + // is added via the copilot ledger below, not a userStats baseline. const copilotCost = userStatsRows.length > 0 ? toNumber(toDecimal(userStatsRows[0].currentPeriodCopilotCost)) : 0 @@ -576,6 +594,25 @@ export async function getSimplifiedBillingSummary( totalLastPeriodCopilotCost = pooled.lastPeriodCopilotCost } + // Add the copilot-family ledger (COPILOT_USAGE_SOURCES: copilot/workspace-chat/ + // mcp_copilot/mothership_block) on top of the baseline; those columns are no + // longer incremented per usage. + const copilotBillingPeriod = + usageData.billingPeriodStart && usageData.billingPeriodEnd + ? { start: usageData.billingPeriodStart, end: usageData.billingPeriodEnd } + : null + if (copilotBillingPeriod) { + const copilotEntity = + orgScoped && subscription?.referenceId + ? ({ type: 'organization', id: subscription.referenceId } as const) + : ({ type: 'user', id: userId } as const) + totalCopilotCost += await getBillingPeriodUsageCost( + copilotEntity, + copilotBillingPeriod, + COPILOT_USAGE_SOURCES + ) + } + const percentUsed = usageData.limit > 0 ? (currentUsage / usageData.limit) * 100 : 0 const daysRemaining = usageData.billingPeriodEnd diff --git a/apps/sim/lib/billing/core/organization.ts b/apps/sim/lib/billing/core/organization.ts index eed717003a..dd3389213f 100644 --- a/apps/sim/lib/billing/core/organization.ts +++ b/apps/sim/lib/billing/core/organization.ts @@ -4,7 +4,10 @@ import { createLogger } from '@sim/logger' import { and, count, eq, gt, ne } from 'drizzle-orm' import { isOrganizationBillingBlocked } from '@/lib/billing/core/access' import { getOrganizationSubscription, getPlanPricing } from '@/lib/billing/core/billing' -import { getBillingPeriodUsageCost } from '@/lib/billing/core/usage-log' +import { + getBillingPeriodUsageCost, + getBillingPeriodUsageCostByUser, +} from '@/lib/billing/core/usage-log' import { computeDailyRefreshConsumed, getOrgMemberRefreshBounds, @@ -50,7 +53,32 @@ interface MemberUsageData { isOverLimit: boolean role: string joinedAt: Date - lastActive: Date | null +} + +/** + * Per-member usage_log cost for an org's current billing period, keyed by userId. + * `currentPeriodCost` is only a baseline (no longer incremented on the hot path), + * so callers add this ledger component to it for each member's real current-period + * usage. Pass `period` to reuse an already-fetched subscription window; omit it to + * look up the org's subscription here. Returns an empty map when there's no period. + */ +export async function getOrgMemberLedgerByUser( + organizationId: string, + period?: { start: Date; end: Date } | null +): Promise> { + let billingPeriod = period ?? null + if (period === undefined) { + const subscription = await getOrganizationSubscription(organizationId) + billingPeriod = + subscription?.periodStart && subscription?.periodEnd + ? { start: subscription.periodStart, end: subscription.periodEnd } + : null + } + if (!billingPeriod) return new Map() + return getBillingPeriodUsageCostByUser( + { type: 'organization', id: organizationId }, + billingPeriod + ) } /** @@ -93,16 +121,25 @@ export async function getOrganizationBillingData( // User stats fields currentPeriodCost: userStats.currentPeriodCost, currentUsageLimit: userStats.currentUsageLimit, - lastActive: userStats.lastActive, }) .from(member) .innerJoin(user, eq(member.userId, user.id)) .leftJoin(userStats, eq(member.userId, userStats.userId)) .where(eq(member.organizationId, organizationId)) + // Per-member current-period usage = userStats baseline + attributed usage_log + // rows. currentPeriodCost is no longer incremented on the hot path, so the + // baseline alone under-reports; add each member's ledger sum for the period. + const billingPeriod = + subscription.periodStart && subscription.periodEnd + ? { start: subscription.periodStart, end: subscription.periodEnd } + : null + const usageByUser = await getOrgMemberLedgerByUser(organizationId, billingPeriod) + // Process member data const members: MemberUsageData[] = membersWithUsage.map((memberRecord) => { - const currentUsage = Number(memberRecord.currentPeriodCost || 0) + const currentUsage = + Number(memberRecord.currentPeriodCost || 0) + (usageByUser.get(memberRecord.userId) ?? 0) const usageLimit = Number(memberRecord.currentUsageLimit || getFreeTierLimit()) const percentUsed = usageLimit > 0 ? (currentUsage / usageLimit) * 100 : 0 @@ -116,17 +153,22 @@ export async function getOrganizationBillingData( isOverLimit: currentUsage > usageLimit, role: memberRecord.role, joinedAt: memberRecord.joinedAt, - lastActive: memberRecord.lastActive, } }) - // Calculate aggregated statistics - let totalCurrentUsage = members.reduce((sum, m) => sum + m.currentUsage, 0) - - if (subscription.periodStart && subscription.periodEnd) { + // Authoritative org total = member baselines + the org's full usage_log for + // the period (also captures rows from members no longer present). Computed + // from raw baselines, NOT members[].currentUsage — the latter already folds + // in per-member usage_log for display, so summing it AND adding the org + // ledger would double-count. + let totalCurrentUsage = membersWithUsage.reduce( + (sum, m) => sum + Number(m.currentPeriodCost || 0), + 0 + ) + if (billingPeriod) { totalCurrentUsage += await getBillingPeriodUsageCost( { type: 'organization', id: subscription.referenceId }, - { start: subscription.periodStart, end: subscription.periodEnd } + billingPeriod ) } diff --git a/apps/sim/lib/billing/core/usage-log.ts b/apps/sim/lib/billing/core/usage-log.ts index 6232a1baf3..ad1657a6db 100644 --- a/apps/sim/lib/billing/core/usage-log.ts +++ b/apps/sim/lib/billing/core/usage-log.ts @@ -4,18 +4,18 @@ import { usageLog } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' -import { and, desc, eq, gte, lte, sql } from 'drizzle-orm' +import { and, desc, eq, gte, inArray, lte, sql } from 'drizzle-orm' import { defaultBillingPeriod } from '@/lib/billing/core/billing-period' import { getHighestPrioritySubscription } from '@/lib/billing/core/plan' import { isOrgScopedSubscription } from '@/lib/billing/subscriptions/utils' -import { isBillingEnabled } from '@/lib/core/config/feature-flags' +import type { DbOrTx } from '@/lib/db/types' const logger = createLogger('UsageLog') /** * Usage log category types */ -export type UsageLogCategory = 'model' | 'fixed' +export type UsageLogCategory = 'model' | 'fixed' | 'tool' /** * Usage log source types @@ -31,6 +31,18 @@ export type UsageLogSource = | 'voice-input' | 'enrichment' +/** + * usage_log sources that make up the "copilot" cost breakdown shown in billing + * summaries: the copilot agent, mothership/workspace chat, MCP copilot, and + * mothership blocks. Mirrors the source set billed via /api/billing/update-cost. + */ +export const COPILOT_USAGE_SOURCES: UsageLogSource[] = [ + 'copilot', + 'workspace-chat', + 'mcp_copilot', + 'mothership_block', +] + /** * Metadata for 'model' category charges */ @@ -47,7 +59,7 @@ export type UsageLogMetadata = ModelUsageMetadata | Record | nu export type BillingEntityType = 'user' | 'organization' -interface BillingEntity { +export interface BillingEntity { type: BillingEntityType id: string } @@ -84,9 +96,16 @@ export interface RecordUsageParams { billingEntity?: BillingEntity /** Billing period bounds, resolved by caller when already known. */ billingPeriod?: { start: Date; end: Date } + /** + * Optional transaction to run the ledger INSERT in. Callers that reconcile a + * read-then-insert under a lock (e.g. the per-execution advisory lock in the + * workflow completion path) pass their tx so the insert participates in the + * same locked transaction. Defaults to the pooled db. + */ + tx?: DbOrTx } -function stableEventKey(parts: Record): string { +export function stableEventKey(parts: Record): string { const payload = Object.keys(parts) .sort() .map((key) => `${key}:${String(parts[key] ?? '')}`) @@ -94,32 +113,53 @@ function stableEventKey(parts: Record): string { return createHash('sha256').update(payload).digest('hex') } +type ResolvedSubscription = Awaited> + +export interface BillingContext { + billingEntity: BillingEntity + billingPeriod: { start: Date; end: Date } +} + +/** + * Derive the billing entity + period from an ALREADY-resolved subscription. + * Callers that already hold the subscription (e.g. the workflow completion path, + * which fetches it for usage-threshold emails) can derive the context once and + * pass it into recordUsage so resolveBillingContext skips a redundant lookup. + * This is the single source of the entity/period derivation — keep it the only + * place that maps a subscription to a billing context. + */ +export function deriveBillingContext( + userId: string, + subscription: ResolvedSubscription +): BillingContext { + const billingEntity: BillingEntity = + subscription && isOrgScopedSubscription(subscription, userId) + ? { type: 'organization', id: subscription.referenceId } + : { type: 'user', id: userId } + + const billingPeriod = + subscription?.periodStart && subscription.periodEnd + ? { start: subscription.periodStart, end: subscription.periodEnd } + : defaultBillingPeriod() + + return { billingEntity, billingPeriod } +} + async function resolveBillingContext( userId: string, billingEntity?: BillingEntity, billingPeriod?: { start: Date; end: Date } -): Promise<{ - billingEntity: BillingEntity - billingPeriod: { start: Date; end: Date } -}> { +): Promise { if (billingEntity && billingPeriod) { return { billingEntity, billingPeriod } } const subscription = await getHighestPrioritySubscription(userId) - const resolvedEntity = - billingEntity ?? - (subscription && isOrgScopedSubscription(subscription, userId) - ? { type: 'organization' as const, id: subscription.referenceId } - : { type: 'user' as const, id: userId }) - - const resolvedPeriod = - billingPeriod ?? - (subscription?.periodStart && subscription.periodEnd - ? { start: subscription.periodStart, end: subscription.periodEnd } - : defaultBillingPeriod()) - - return { billingEntity: resolvedEntity, billingPeriod: resolvedPeriod } + const derived = deriveBillingContext(userId, subscription) + return { + billingEntity: billingEntity ?? derived.billingEntity, + billingPeriod: billingPeriod ?? derived.billingPeriod, + } } /** @@ -128,43 +168,55 @@ async function resolveBillingContext( */ export async function getBillingPeriodUsageCost( billingEntity: BillingEntity, - billingPeriod: { start: Date; end: Date } + billingPeriod: { start: Date; end: Date }, + source?: UsageLogSource | UsageLogSource[] ): Promise { + const conditions = [ + eq(usageLog.billingEntityType, billingEntity.type), + eq(usageLog.billingEntityId, billingEntity.id), + eq(usageLog.billingPeriodStart, billingPeriod.start), + eq(usageLog.billingPeriodEnd, billingPeriod.end), + ] + if (source) { + conditions.push( + Array.isArray(source) ? inArray(usageLog.source, source) : eq(usageLog.source, source) + ) + } + const [row] = await db .select({ cost: sql`COALESCE(SUM(${usageLog.cost}), 0)`, }) .from(usageLog) - .where( - and( - eq(usageLog.billingEntityType, billingEntity.type), - eq(usageLog.billingEntityId, billingEntity.id), - eq(usageLog.billingPeriodStart, billingPeriod.start), - eq(usageLog.billingPeriodEnd, billingPeriod.end) - ) - ) + .where(and(...conditions)) return Number.parseFloat(row?.cost ?? '0') } export async function getBillingPeriodUsageCostByUser( billingEntity: BillingEntity, - billingPeriod: { start: Date; end: Date } + billingPeriod: { start: Date; end: Date }, + source?: UsageLogSource | UsageLogSource[] ): Promise> { + const conditions = [ + eq(usageLog.billingEntityType, billingEntity.type), + eq(usageLog.billingEntityId, billingEntity.id), + eq(usageLog.billingPeriodStart, billingPeriod.start), + eq(usageLog.billingPeriodEnd, billingPeriod.end), + ] + if (source) { + conditions.push( + Array.isArray(source) ? inArray(usageLog.source, source) : eq(usageLog.source, source) + ) + } + const rows = await db .select({ userId: usageLog.userId, cost: sql`COALESCE(SUM(${usageLog.cost}), 0)`, }) .from(usageLog) - .where( - and( - eq(usageLog.billingEntityType, billingEntity.type), - eq(usageLog.billingEntityId, billingEntity.id), - eq(usageLog.billingPeriodStart, billingPeriod.start), - eq(usageLog.billingPeriodEnd, billingPeriod.end) - ) - ) + .where(and(...conditions)) .groupBy(usageLog.userId) return new Map(rows.map((row) => [row.userId, Number.parseFloat(row.cost ?? '0')])) @@ -178,12 +230,20 @@ export async function getBillingPeriodUsageCostByUser( * but usage writes no longer contend on the user_stats row. */ export async function recordUsage(params: RecordUsageParams): Promise { - if (!isBillingEnabled) { - return - } - - const { userId, entries, workspaceId, workflowId, executionId, billingEntity, billingPeriod } = - params + // The usage ledger is written regardless of BILLING_ENABLED so it is the + // single, universal source of truth for cost (including self-hosted, where + // it powers the logs-page cost display). Billing *enforcement* (Stripe / + // overage) is gated separately by callers, not here. + const { + userId, + entries, + workspaceId, + workflowId, + executionId, + billingEntity, + billingPeriod, + tx, + } = params const validEntries = entries.filter((e) => e.cost > 0) @@ -193,7 +253,7 @@ export async function recordUsage(params: RecordUsageParams): Promise { const context = await resolveBillingContext(userId, billingEntity, billingPeriod) - const insertedRows = await db + const insertedRows = await (tx ?? db) .insert(usageLog) .values( validEntries.map((entry, index) => { diff --git a/apps/sim/lib/billing/core/usage.ts b/apps/sim/lib/billing/core/usage.ts index 06eba35faa..972a199b0f 100644 --- a/apps/sim/lib/billing/core/usage.ts +++ b/apps/sim/lib/billing/core/usage.ts @@ -607,51 +607,6 @@ export async function syncUsageLimitsFromSubscription(userId: string): Promise -> { - try { - const teamMembers = await db - .select({ - userId: member.userId, - userName: user.name, - userEmail: user.email, - currentLimit: userStats.currentUsageLimit, - currentPeriodCost: userStats.currentPeriodCost, - totalCost: userStats.totalCost, - lastActive: userStats.lastActive, - }) - .from(member) - .innerJoin(user, eq(member.userId, user.id)) - .leftJoin(userStats, eq(member.userId, userStats.userId)) - .where(eq(member.organizationId, organizationId)) - - return teamMembers.map((memberData) => ({ - userId: memberData.userId, - userName: memberData.userName, - userEmail: memberData.userEmail, - currentLimit: toNumber(toDecimal(memberData.currentLimit || getFreeTierLimit().toString())), - currentUsage: toNumber(toDecimal(memberData.currentPeriodCost)), - totalCost: toNumber(toDecimal(memberData.totalCost)), - lastActive: memberData.lastActive, - })) - } catch (error) { - logger.error('Failed to get team usage limits', { organizationId, error }) - return [] - } -} - /** * Returns the effective current period usage cost for a user, with daily * refresh credits deducted. Org-scoped subs return the pooled sum across diff --git a/apps/sim/lib/billing/credits/conversion.test.ts b/apps/sim/lib/billing/credits/conversion.test.ts new file mode 100644 index 0000000000..7d4a45a373 --- /dev/null +++ b/apps/sim/lib/billing/credits/conversion.test.ts @@ -0,0 +1,65 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { + apportionCredits, + dollarsToCredits, + formatCreditCost, +} from '@/lib/billing/credits/conversion' + +describe('formatCreditCost', () => { + it('renders multiplier-inclusive dollars as a single-rounded credit label', () => { + expect(formatCreditCost(0.005)).toBe('1 credit') + expect(formatCreditCost(0.03141848)).toBe('6 credits') + expect(formatCreditCost(1.234)).toBe('247 credits') + }) + + it('distinguishes sub-credit charges from zero', () => { + expect(formatCreditCost(0.001)).toBe('<1 credit') + expect(formatCreditCost(0)).toBe('0 credits') + }) + + it('honors emptyForZeroOrLess for the trace view contract', () => { + expect(formatCreditCost(0, { emptyForZeroOrLess: true })).toBeUndefined() + expect(formatCreditCost(undefined, { emptyForZeroOrLess: true })).toBeUndefined() + expect(formatCreditCost(undefined)).toBe('—') + }) +}) + +describe('apportionCredits', () => { + it('keeps line items summing exactly to the rounded total (no round-then-sum drift)', () => { + // Real execution 43ef064d: base + 2x model, multiplier already applied. + // Round-then-sum would give 1 + 4 + 2 = 7; the true total is 6. + const credits = apportionCredits([ + { key: 'base', dollars: 0.005 }, + { key: 'input', dollars: 0.018798 }, + { key: 'output', dollars: 0.00762 }, + { key: 'tool', dollars: 0 }, + ]) + + const total = dollarsToCredits(0.005 + 0.018798 + 0.00762 + 0) + expect(total).toBe(6) + expect(credits.base + credits.input + credits.output + credits.tool).toBe(total) + expect(credits.base).toBe(1) + }) + + it('handles all-zero components', () => { + const credits = apportionCredits([ + { key: 'base', dollars: 0 }, + { key: 'model', dollars: 0 }, + ]) + expect(credits.base + credits.model).toBe(0) + }) + + it('ignores negative/non-finite components without throwing', () => { + const credits = apportionCredits([ + { key: 'base', dollars: 0.005 }, + { key: 'model', dollars: Number.NaN }, + { key: 'tool', dollars: -1 }, + ]) + expect(credits.base).toBe(1) + expect(credits.model).toBe(0) + expect(credits.tool).toBe(0) + }) +}) diff --git a/apps/sim/lib/billing/credits/conversion.ts b/apps/sim/lib/billing/credits/conversion.ts index a4f241f995..74a77b8fda 100644 --- a/apps/sim/lib/billing/credits/conversion.ts +++ b/apps/sim/lib/billing/credits/conversion.ts @@ -12,6 +12,83 @@ export function dollarsToCredits(dollars: number): number { return Math.round(dollars * CREDIT_MULTIPLIER) } +/** + * Single source of truth for rendering a dollar cost as a credit label. + * + * Both the billing cost breakdown and the trace view derive their credit + * strings from here so the two surfaces can never diverge in rounding, + * thresholds, or pluralization. The dollar amount passed in is expected to + * already carry any cost multiplier (the value is converted with one — and + * only one — round via `dollarsToCredits`, i.e. multiply-then-round; never + * round per-line then multiply). + * + * `emptyForZeroOrLess` controls the zero/empty behavior so existing call sites + * keep their contracts: the breakdown wants a concrete "0 credits"/"—" string, + * the trace view wants `undefined` so it can hide the chip entirely. + */ +export function formatCreditCost( + dollars: number | null | undefined, + opts?: { emptyForZeroOrLess?: boolean } +): string | undefined { + if (dollars === undefined || dollars === null || !Number.isFinite(dollars)) { + return opts?.emptyForZeroOrLess ? undefined : '—' + } + + const credits = dollarsToCredits(dollars) + + if (credits <= 0) { + if (dollars > 0) return '<1 credit' + return opts?.emptyForZeroOrLess ? undefined : '0 credits' + } + + return `${credits.toLocaleString()} ${credits === 1 ? 'credit' : 'credits'}` +} + +/** + * Splits a set of cost components into integer credits that sum *exactly* to + * the credits of their combined total. + * + * This is the fix for the "line items don't add up to the total" class of bug: + * rounding each line independently (round-then-sum) drifts from the real charge + * (e.g. 1 + 2 + 1 = 4, or 1 + 4 + 2 = 7, when the true total is 6). Instead we + * convert the *summed* dollars to credits with a single round (multiply-then- + * round) and distribute that figure across the components via the largest- + * remainder method, so every component is multiplier-applied, internally + * consistent, and the rows always reconcile with the total. + * + * Each component's `dollars` is expected to already include any cost multiplier. + */ +export function apportionCredits( + components: { key: K; dollars: number }[] +): Record { + const result = {} as Record + + const sanitized = components.map((c) => ({ + key: c.key, + dollars: Number.isFinite(c.dollars) && c.dollars > 0 ? c.dollars : 0, + })) + + const totalDollars = sanitized.reduce((sum, c) => sum + c.dollars, 0) + const targetCredits = dollarsToCredits(totalDollars) + + const exact = sanitized.map((c) => ({ + key: c.key, + floor: Math.floor(c.dollars * CREDIT_MULTIPLIER), + frac: c.dollars * CREDIT_MULTIPLIER - Math.floor(c.dollars * CREDIT_MULTIPLIER), + })) + + for (const c of exact) result[c.key] = c.floor + + let remainder = targetCredits - exact.reduce((sum, c) => sum + c.floor, 0) + const byFraction = [...exact].sort((a, b) => b.frac - a.frac) + for (let i = 0; i < byFraction.length && remainder > 0; i++) { + result[byFraction[i].key] += 1 + remainder-- + } + + return result +} + /** * Format a dollar amount as a comma-separated credit string. * Values at or above the on-demand unlimited threshold display as ∞. diff --git a/apps/sim/lib/billing/index.ts b/apps/sim/lib/billing/index.ts index 8c01b7928c..0df31e11d8 100644 --- a/apps/sim/lib/billing/index.ts +++ b/apps/sim/lib/billing/index.ts @@ -25,7 +25,6 @@ export { export * from '@/lib/billing/core/usage' export { checkUsageStatus, - getTeamUsageLimits, getUserUsageData as getUsageData, getUserUsageLimit as getUsageLimit, updateUserUsageLimit as updateUsageLimit, diff --git a/apps/sim/lib/billing/types/index.ts b/apps/sim/lib/billing/types/index.ts index db8b5adde3..c90d0991c9 100644 --- a/apps/sim/lib/billing/types/index.ts +++ b/apps/sim/lib/billing/types/index.ts @@ -137,8 +137,6 @@ interface TeamUsageLimit { userEmail: string currentLimit: number currentUsage: number - totalCost: number - lastActive: Date | null limitSetBy: string | null limitUpdatedAt: Date | null } diff --git a/apps/sim/lib/billing/validation/seat-management.ts b/apps/sim/lib/billing/validation/seat-management.ts index df9a83d8e7..230ebee46a 100644 --- a/apps/sim/lib/billing/validation/seat-management.ts +++ b/apps/sim/lib/billing/validation/seat-management.ts @@ -1,5 +1,5 @@ import { db } from '@sim/db' -import { invitation, member, organization, subscription, user, userStats } from '@sim/db/schema' +import { invitation, member, organization, subscription, user } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, count, eq, gt, ne } from 'drizzle-orm' import { getOrganizationSubscription } from '@/lib/billing/core/billing' @@ -307,35 +307,15 @@ export async function getOrganizationSeatAnalytics(organizationId: string) { return null } - const memberActivity = await db - .select({ - userId: member.userId, - userName: user.name, - userEmail: user.email, - role: member.role, - joinedAt: member.createdAt, - lastActive: userStats.lastActive, - }) - .from(member) - .innerJoin(user, eq(member.userId, user.id)) - .leftJoin(userStats, eq(member.userId, userStats.userId)) - .where(eq(member.organizationId, organizationId)) - const utilizationRate = seatInfo.maxSeats > 0 ? (seatInfo.currentSeats / seatInfo.maxSeats) * 100 : 0 - const recentlyActive = memberActivity.filter((memberData) => { - if (!memberData.lastActive) return false - const daysSinceActive = (Date.now() - memberData.lastActive.getTime()) / (1000 * 60 * 60 * 24) - return daysSinceActive <= 30 // Active in last 30 days - }).length - + // Member activity analytics (active/inactive counts, memberActivity) were + // derived from userStats.lastActive, which is no longer written. Dropped + // rather than report frozen data; reintroduce with a real activity source. return { ...seatInfo, utilizationRate: Math.round(utilizationRate * 100) / 100, - activeMembers: recentlyActive, - inactiveMembers: seatInfo.currentSeats - recentlyActive, - memberActivity, } } catch (error) { logger.error('Failed to get organization seat analytics', { organizationId, error }) diff --git a/apps/sim/lib/billing/webhooks/invoices.ts b/apps/sim/lib/billing/webhooks/invoices.ts index 0a30d364f3..9460f52162 100644 --- a/apps/sim/lib/billing/webhooks/invoices.ts +++ b/apps/sim/lib/billing/webhooks/invoices.ts @@ -13,7 +13,10 @@ import type Stripe from 'stripe' import { getEmailSubject, PaymentFailedEmail, renderCreditPurchaseEmail } from '@/components/emails' import { BILLING_LOCK_TIMEOUT_MS } from '@/lib/billing/constants' import { calculateSubscriptionOverage, isSubscriptionOrgScoped } from '@/lib/billing/core/billing' -import { getBillingPeriodUsageCostByUser } from '@/lib/billing/core/usage-log' +import { + COPILOT_USAGE_SOURCES, + getBillingPeriodUsageCostByUser, +} from '@/lib/billing/core/usage-log' import { addCredits, getCreditBalanceForEntity } from '@/lib/billing/credits/balance' import { setUsageLimitForCredits } from '@/lib/billing/credits/purchase' import { blockOrgMembers, unblockOrgMembers } from '@/lib/billing/organizations/membership' @@ -424,6 +427,15 @@ export async function resetUsageForSubscription(sub: { billingPeriod ) : new Map() + // Copilot-family ledger per user, so last-period copilot mirrors last-period + // cost (baseline + usage_log) instead of capturing the baseline alone. + const copilotLedgerByUser = billingPeriod + ? await getBillingPeriodUsageCostByUser( + { type: 'organization', id: sub.referenceId }, + billingPeriod, + COPILOT_USAGE_SOURCES + ) + : new Map() await db.transaction(async (tx) => { await tx.execute(sql.raw(`SET LOCAL lock_timeout = '${BILLING_LOCK_TIMEOUT_MS}ms'`)) @@ -494,15 +506,27 @@ export async function resetUsageForSubscription(sub: { memberStatsRows.map((row) => sql`WHEN ${row.userId} THEN ${row.currentCopilot ?? '0'}`), sql` ` ) + // Last-period copilot = baseline copilot + copilot-family ledger, mirroring + // lastPeriodCost. (The reset below still subtracts only the baseline, since + // the ledger is period-scoped and rolls over on its own.) + const lastCopilotCostByUser = sql.join( + memberStatsRows.map((row) => { + const baselineCopilot = toNumber(toDecimal(row.currentCopilot)) + const copilotLedger = copilotLedgerByUser.get(row.userId) ?? 0 + return sql`WHEN ${row.userId} THEN ${(baselineCopilot + copilotLedger).toString()}` + }), + sql` ` + ) const capturedLastCost = sql`CASE ${userStats.userId} ${lastCostByUser} ELSE '0' END` const capturedCurrentCost = sql`CASE ${userStats.userId} ${currentCostByUser} ELSE '0' END` const capturedCurrentCopilotCost = sql`CASE ${userStats.userId} ${currentCopilotCostByUser} ELSE '0' END` + const capturedLastCopilotCost = sql`CASE ${userStats.userId} ${lastCopilotCostByUser} ELSE '0' END` await tx .update(userStats) .set({ lastPeriodCost: capturedLastCost, - lastPeriodCopilotCost: capturedCurrentCopilotCost, + lastPeriodCopilotCost: capturedLastCopilotCost, currentPeriodCost: sql`GREATEST(0, ${userStats.currentPeriodCost} - (${capturedCurrentCost})::decimal)`, currentPeriodCopilotCost: sql`GREATEST(0, ${userStats.currentPeriodCopilotCost} - (${capturedCurrentCopilotCost})::decimal)`, billedOverageThisPeriod: '0', @@ -536,6 +560,15 @@ export async function resetUsageForSubscription(sub: { ) : new Map() const userLedgerUsage = ledgerUsage.get(sub.referenceId) ?? 0 + const copilotLedgerUsage = billingPeriod + ? (( + await getBillingPeriodUsageCostByUser( + { type: 'user', id: sub.referenceId }, + billingPeriod, + COPILOT_USAGE_SOURCES + ) + ).get(sub.referenceId) ?? 0) + : 0 // Snapshot > 0: user joined a paid org mid-cycle. The pre-join // portion was billed on this invoice (snapshot); `currentPeriodCost` @@ -546,7 +579,12 @@ export async function resetUsageForSubscription(sub: { .update(userStats) .set({ lastPeriodCost: (snapshot + userLedgerUsage).toString(), - lastPeriodCopilotCost: '0', + // Pre-join personal copilot = the user-scoped copilot ledger only + // (post-join copilot usage is org-attributed, so this captures the + // pre-join portion). The copilot baseline stays with the org via the + // retained currentPeriodCopilotCost, so don't add it here (avoids a + // double count at the org's cycle-close). + lastPeriodCopilotCost: copilotLedgerUsage.toString(), proPeriodCostSnapshot: '0', proPeriodCostSnapshotAt: null, billedOverageThisPeriod: '0', @@ -563,7 +601,9 @@ export async function resetUsageForSubscription(sub: { .update(userStats) .set({ lastPeriodCost: totalLastPeriod, - lastPeriodCopilotCost: currentCopilot, + lastPeriodCopilotCost: ( + toNumber(toDecimal(currentCopilot)) + copilotLedgerUsage + ).toString(), currentPeriodCost: sql`GREATEST(0, ${userStats.currentPeriodCost} - ${current}::decimal)`, currentPeriodCopilotCost: sql`GREATEST(0, ${userStats.currentPeriodCopilotCost} - ${currentCopilot}::decimal)`, proPeriodCostSnapshot: '0', diff --git a/apps/sim/lib/copilot/chat/process-contents.ts b/apps/sim/lib/copilot/chat/process-contents.ts index 21b6842cd6..ea60844218 100644 --- a/apps/sim/lib/copilot/chat/process-contents.ts +++ b/apps/sim/lib/copilot/chat/process-contents.ts @@ -639,6 +639,7 @@ async function processExecutionLogFromDb( .select({ id: workflowExecutionLogs.id, workflowId: workflowExecutionLogs.workflowId, + workspaceId: workflowExecutionLogs.workspaceId, executionId: workflowExecutionLogs.executionId, level: workflowExecutionLogs.level, trigger: workflowExecutionLogs.trigger, @@ -646,7 +647,7 @@ async function processExecutionLogFromDb( endedAt: workflowExecutionLogs.endedAt, totalDurationMs: workflowExecutionLogs.totalDurationMs, executionData: workflowExecutionLogs.executionData, - cost: workflowExecutionLogs.cost, + costTotal: workflowExecutionLogs.costTotal, workflowName: workflow.name, }) .from(workflowExecutionLogs) @@ -671,6 +672,13 @@ async function processExecutionLogFromDb( } } + // Heavy execution data may live in object storage; resolve the pointer. + const { materializeExecutionData } = await import('@/lib/logs/execution/trace-store') + const executionData = (await materializeExecutionData( + log.executionData as Record | null, + { workspaceId: log.workspaceId, workflowId: log.workflowId, executionId: log.executionId } + )) as any + const summary = { id: log.id, workflowId: log.workflowId, @@ -681,13 +689,13 @@ async function processExecutionLogFromDb( endedAt: log.endedAt?.toISOString?.() || (log.endedAt ? String(log.endedAt) : null), totalDurationMs: log.totalDurationMs ?? null, workflowName: log.workflowName || '', - executionData: log.executionData + executionData: executionData ? { - traceSpans: (log.executionData as any).traceSpans || undefined, - errorDetails: (log.executionData as any).errorDetails || undefined, + traceSpans: executionData.traceSpans || undefined, + errorDetails: executionData.errorDetails || undefined, } : undefined, - cost: log.cost || undefined, + cost: log.costTotal != null ? { total: Number(log.costTotal) } : undefined, } const content = JSON.stringify(summary) diff --git a/apps/sim/lib/copilot/tools/server/workflow/get-execution-summary.ts b/apps/sim/lib/copilot/tools/server/workflow/get-execution-summary.ts index f561d7b619..10f18a192b 100644 --- a/apps/sim/lib/copilot/tools/server/workflow/get-execution-summary.ts +++ b/apps/sim/lib/copilot/tools/server/workflow/get-execution-summary.ts @@ -4,6 +4,7 @@ import { createLogger } from '@sim/logger' import { and, desc, eq, type SQL } from 'drizzle-orm' import { GetExecutionSummary } from '@/lib/copilot/generated/tool-catalog-v1' import type { BaseServerTool, ServerToolContext } from '@/lib/copilot/tools/server/base-tool' +import { materializeExecutionData } from '@/lib/logs/execution/trace-store' import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' const logger = createLogger('GetExecutionSummaryServerTool') @@ -86,13 +87,14 @@ export const getExecutionSummaryServerTool: BaseServerTool< .select({ executionId: workflowExecutionLogs.executionId, workflowId: workflowExecutionLogs.workflowId, + workspaceId: workflowExecutionLogs.workspaceId, workflowName: workflow.name, status: workflowExecutionLogs.status, level: workflowExecutionLogs.level, trigger: workflowExecutionLogs.trigger, startedAt: workflowExecutionLogs.startedAt, totalDurationMs: workflowExecutionLogs.totalDurationMs, - cost: workflowExecutionLogs.cost, + costTotal: workflowExecutionLogs.costTotal, executionData: workflowExecutionLogs.executionData, }) .from(workflowExecutionLogs) @@ -101,26 +103,36 @@ export const getExecutionSummaryServerTool: BaseServerTool< .orderBy(desc(workflowExecutionLogs.startedAt)) .limit(clampedLimit) - const summaries: ExecutionSummary[] = rows.map((row) => { - const costData = row.cost as any - const errorMsg = row.level === 'error' ? extractErrorMessage(row.executionData) : null + const summaries: ExecutionSummary[] = await Promise.all( + rows.map(async (row) => { + // Only externalized rows need a fetch; error fields live in the heavy data. + const executionData = + row.level === 'error' + ? await materializeExecutionData(row.executionData as Record | null, { + workspaceId: row.workspaceId, + workflowId: row.workflowId, + executionId: row.executionId, + }) + : row.executionData + const errorMsg = row.level === 'error' ? extractErrorMessage(executionData) : null - return { - executionId: row.executionId, - workflowId: row.workflowId, - workflowName: row.workflowName, - status: row.status, - trigger: row.trigger, - startedAt: row.startedAt.toISOString(), - durationMs: row.totalDurationMs ?? null, - cost: costData?.total ? Number(costData.total) : null, - error: errorMsg - ? typeof errorMsg === 'string' - ? errorMsg - : JSON.stringify(errorMsg) - : null, - } - }) + return { + executionId: row.executionId, + workflowId: row.workflowId, + workflowName: row.workflowName, + status: row.status, + trigger: row.trigger, + startedAt: row.startedAt.toISOString(), + durationMs: row.totalDurationMs ?? null, + cost: row.costTotal != null ? Number(row.costTotal) : null, + error: errorMsg + ? typeof errorMsg === 'string' + ? errorMsg + : JSON.stringify(errorMsg) + : null, + } + }) + ) logger.info('Execution summary prepared', { count: summaries.length, diff --git a/apps/sim/lib/copilot/tools/server/workflow/get-workflow-logs.ts b/apps/sim/lib/copilot/tools/server/workflow/get-workflow-logs.ts index 0daf2aa07d..471bc38805 100644 --- a/apps/sim/lib/copilot/tools/server/workflow/get-workflow-logs.ts +++ b/apps/sim/lib/copilot/tools/server/workflow/get-workflow-logs.ts @@ -5,6 +5,7 @@ import { authorizeWorkflowByWorkspacePermission } from '@sim/workflow-authz' import { and, desc, eq } from 'drizzle-orm' import { GetWorkflowLogs } from '@/lib/copilot/generated/tool-catalog-v1' import type { BaseServerTool } from '@/lib/copilot/tools/server/base-tool' +import { materializeExecutionData } from '@/lib/logs/execution/trace-store' import type { TraceSpan } from '@/lib/logs/types' const logger = createLogger('GetWorkflowLogsServerTool') @@ -145,6 +146,8 @@ export const getWorkflowLogsServerTool: BaseServerTool { - const executionData = log.executionData as ExecutionData - const traceSpans = executionData?.traceSpans ?? [] - const blockExecutions = includeDetails - ? extractBlockExecutionsFromTraceSpans(traceSpans) - : [] + const simplifiedExecutions: SimplifiedExecution[] = await Promise.all( + executionLogs.map(async (log) => { + const executionData = (await materializeExecutionData( + log.executionData as Record | null, + { + workspaceId: log.workspaceId, + workflowId: log.workflowId, + executionId: log.executionId, + } + )) as ExecutionData + const traceSpans = executionData?.traceSpans ?? [] + const blockExecutions = includeDetails + ? extractBlockExecutionsFromTraceSpans(traceSpans) + : [] - const simplifiedBlocks: SimplifiedBlock[] = blockExecutions.map((block) => ({ - id: block.blockId, - name: block.blockName, - startedAt: block.startedAt, - endedAt: block.endedAt, - durationMs: block.durationMs, - output: block.outputData, - error: block.status === 'error' ? block.errorMessage : undefined, - })) + const simplifiedBlocks: SimplifiedBlock[] = blockExecutions.map((block) => ({ + id: block.blockId, + name: block.blockName, + startedAt: block.startedAt, + endedAt: block.endedAt, + durationMs: block.durationMs, + output: block.outputData, + error: block.status === 'error' ? block.errorMessage : undefined, + })) - const rawError = - executionData?.errorDetails?.error || - executionData?.errorDetails?.message || - executionData?.finalOutput?.error || - executionData?.error || - null - const errorMessage = rawError - ? typeof rawError === 'string' - ? rawError - : JSON.stringify(rawError) - : undefined + const rawError = + executionData?.errorDetails?.error || + executionData?.errorDetails?.message || + executionData?.finalOutput?.error || + executionData?.error || + null + const errorMessage = rawError + ? typeof rawError === 'string' + ? rawError + : JSON.stringify(rawError) + : undefined - return { - id: log.id, - executionId: log.executionId, - status: log.status, - startedAt: log.startedAt.toISOString(), - endedAt: log.endedAt ? log.endedAt.toISOString() : null, - durationMs: log.totalDurationMs ?? null, - ...(errorMessage ? { error: errorMessage } : {}), - ...(simplifiedBlocks.length > 0 ? { blocks: simplifiedBlocks } : {}), - } - }) + return { + id: log.id, + executionId: log.executionId, + status: log.status, + startedAt: log.startedAt.toISOString(), + endedAt: log.endedAt ? log.endedAt.toISOString() : null, + durationMs: log.totalDurationMs ?? null, + ...(errorMessage ? { error: errorMessage } : {}), + ...(simplifiedBlocks.length > 0 ? { blocks: simplifiedBlocks } : {}), + } + }) + ) const resultSize = JSON.stringify(simplifiedExecutions).length logger.info('Workflow logs result prepared', { diff --git a/apps/sim/lib/core/utils/concurrency.ts b/apps/sim/lib/core/utils/concurrency.ts new file mode 100644 index 0000000000..9156c3c83d --- /dev/null +++ b/apps/sim/lib/core/utils/concurrency.ts @@ -0,0 +1,35 @@ +/** + * Maps over `items` with at most `limit` concurrent invocations of `fn`, + * preserving input order in the result. Use to bound fan-out (e.g. per-row + * object-storage reads) so a large batch doesn't issue every request at once. + * + * Contract: `fn` MUST NOT reject. Results are awaited via `Promise.all`, so a + * single rejection fails the entire batch (e.g. one bad row would break a whole + * logs export/list page). Callers that materialize per-row data pass a total + * mapper (one that catches and returns a degraded value rather than throwing); + * keep it that way. If a future caller needs per-item isolation, add an + * allSettled-style variant rather than letting a throwing mapper through here. + */ +export async function mapWithConcurrency( + items: readonly T[], + limit: number, + fn: (item: T, index: number) => Promise +): Promise { + const results = new Array(items.length) + const workerCount = Math.max(1, Math.min(limit, items.length)) + let cursor = 0 + + const worker = async (): Promise => { + while (true) { + const index = cursor++ + if (index >= items.length) return + results[index] = await fn(items[index], index) + } + } + + await Promise.all(Array.from({ length: workerCount }, worker)) + return results +} + +/** Default bound for per-row object-storage materialization fan-out. */ +export const MATERIALIZE_CONCURRENCY = 20 diff --git a/apps/sim/lib/data-drains/sources/workflow-logs.ts b/apps/sim/lib/data-drains/sources/workflow-logs.ts index 22487388f8..b64ce15479 100644 --- a/apps/sim/lib/data-drains/sources/workflow-logs.ts +++ b/apps/sim/lib/data-drains/sources/workflow-logs.ts @@ -1,6 +1,7 @@ import { db } from '@sim/db' import { workflowExecutionLogs } from '@sim/db/schema' import { and, inArray, isNotNull } from 'drizzle-orm' +import { MATERIALIZE_CONCURRENCY, mapWithConcurrency } from '@/lib/core/utils/concurrency' import { decodeTimeCursor, encodeTimeCursor, @@ -9,6 +10,7 @@ import { } from '@/lib/data-drains/sources/cursor' import { getOrganizationWorkspaceIds } from '@/lib/data-drains/sources/helpers' import type { Cursor, DrainSource, SourcePageInput } from '@/lib/data-drains/types' +import { materializeExecutionData } from '@/lib/logs/execution/trace-store' type WorkflowLogRow = typeof workflowExecutionLogs.$inferSelect @@ -45,6 +47,22 @@ async function* pages(input: SourcePageInput): AsyncIterable { .limit(input.chunkSize) if (rows.length === 0) return + + // Heavy execution data may live in object storage; resolve pointers (bounded + // concurrency) so the drain exports full execution data, not the slim row. + // Use the order-preserving returned array (the util's documented contract) + // and write back, rather than mutating rows inside the mapper. + const materialized = await mapWithConcurrency(rows, MATERIALIZE_CONCURRENCY, (row) => + materializeExecutionData(row.executionData as Record | null, { + workspaceId: row.workspaceId, + workflowId: row.workflowId, + executionId: row.executionId, + }) + ) + for (let i = 0; i < rows.length; i++) { + rows[i].executionData = materialized[i] as WorkflowLogRow['executionData'] + } + yield rows const last = rows[rows.length - 1] cursor = { ts: last.endedAt!.toISOString(), id: last.id } @@ -71,7 +89,8 @@ export const workflowLogsSource: DrainSource = { endedAt: row.endedAt ? row.endedAt.toISOString() : null, totalDurationMs: row.totalDurationMs, executionData: row.executionData, - cost: row.cost, + // cost_total projection of the usage_log ledger (not the deprecated jsonb). + cost: row.costTotal != null ? { total: Number(row.costTotal) } : null, files: row.files, createdAt: row.createdAt.toISOString(), } diff --git a/apps/sim/lib/execution/payloads/store.ts b/apps/sim/lib/execution/payloads/store.ts index f13af025eb..d662447aaa 100644 --- a/apps/sim/lib/execution/payloads/store.ts +++ b/apps/sim/lib/execution/payloads/store.ts @@ -31,6 +31,13 @@ export interface LargeValueStoreContext { userId?: string requireDurable?: boolean maxBytes?: number + /** + * When false, materialization does not register an execution_log reference for + * the key. Read-only consumers (e.g. viewing/exporting a completed log) set + * this: the value is already owned + referenced by its own execution, so + * re-registering on every read is wasteful and a needless failure point. + */ + trackReference?: boolean } function getKind(value: unknown): LargeValueKind { @@ -163,6 +170,11 @@ export async function storeLargeValue( const id = `lv_${generateShortId(12)}` let key = await persistValue(id, json, context) if (key) { + // Only clean up the uploaded object when registration definitively did NOT + // record ownership (returns false). If registration THROWS, the metadata + // state is uncertain (a row may have partially committed), so we propagate + // without deleting — deleting could orphan a metadata row pointing at a + // now-missing object. const registered = await registerPersistedValueOwner(key, size, referencedKeys, context) if (!registered) { await deleteUntrackedPersistedValue(key) @@ -204,16 +216,22 @@ export async function materializeLargeValueRef( return materializeLargeValueRefSync(ref, context) } - const { addLargeValueReference } = await import('@/lib/execution/payloads/large-value-metadata') - await addLargeValueReference( - { - workspaceId: context.workspaceId, - workflowId: context.workflowId, - executionId: context.executionId, - source: 'execution_log', - }, - ref.key - ) + if (context.trackReference !== false) { + const { addLargeValueReference } = await import('@/lib/execution/payloads/large-value-metadata') + // Reference tracking is GC-critical: if it fails, fail the read rather than + // return a value whose reference was never recorded (it could later be + // garbage-collected out from under a live consumer). Read-only consumers + // that don't need a reference set trackReference: false to skip this. + await addLargeValueReference( + { + workspaceId: context.workspaceId, + workflowId: context.workflowId, + executionId: context.executionId, + source: 'execution_log', + }, + ref.key + ) + } try { const cached = materializeLargeValueRefSync(ref, context) diff --git a/apps/sim/lib/logs/execution/logger.test.ts b/apps/sim/lib/logs/execution/logger.test.ts index 1cca0faca7..cf245552cc 100644 --- a/apps/sim/lib/logs/execution/logger.test.ts +++ b/apps/sim/lib/logs/execution/logger.test.ts @@ -1,7 +1,37 @@ import { featureFlagsMock } from '@sim/testing' import { beforeEach, describe, expect, test, vi } from 'vitest' +import { recordUsage } from '@/lib/billing/core/usage-log' import { ExecutionLogger } from '@/lib/logs/execution/logger' +const dbSelectMock = vi.hoisted(() => vi.fn()) +const dbExecuteMock = vi.hoisted(() => vi.fn()) +const txUpdateMock = vi.hoisted(() => + vi.fn(() => ({ set: () => ({ where: () => Promise.resolve() }) })) +) + +vi.mock('@sim/db', () => { + // The reconcile runs inside db.transaction with an advisory lock. The tx + // shares dbSelectMock so the existing call-order seeding (call 1 = workflow + // row via .limit, call 2 = already-billed via .groupBy) still applies; + // tx.execute (set_config + pg_advisory_xact_lock) is a no-op; tx.update backs + // the exact cost_total refine. + const tx = { + select: dbSelectMock, + insert: vi.fn(), + update: txUpdateMock, + execute: dbExecuteMock, + } + return { + db: { + select: dbSelectMock, + insert: vi.fn(), + update: vi.fn(), + execute: dbExecuteMock, + transaction: vi.fn(async (cb: (txArg: typeof tx) => Promise) => cb(tx)), + }, + } +}) + // Mock billing modules vi.mock('@/lib/billing/core/subscription', () => ({ getHighestPrioritySubscription: vi.fn(() => Promise.resolve(null)), @@ -19,6 +49,7 @@ vi.mock('@/lib/billing/core/usage', () => ({ vi.mock('@/lib/billing/core/usage-log', () => ({ recordUsage: vi.fn(() => Promise.resolve()), + stableEventKey: vi.fn((parts: Record) => JSON.stringify(parts)), })) vi.mock('@/lib/billing/threshold-billing', () => ({ @@ -439,3 +470,322 @@ describe('ExecutionLogger', () => { }) }) }) + +describe('recordExecutionUsage boundary-delta reconciliation', () => { + let logger: any + + beforeEach(() => { + logger = new ExecutionLogger() as any + vi.clearAllMocks() + }) + + const costSummary = (overrides: Record = {}) => ({ + totalCost: 0, + totalInputCost: 0, + totalOutputCost: 0, + totalTokens: 0, + totalPromptTokens: 0, + totalCompletionTokens: 0, + baseExecutionCharge: 0.005, + models: {}, + charges: {}, + ...overrides, + }) + + // db.select() is called twice in recordExecutionUsage: first the workflow row + // (terminated by .limit), then the already-billed usage_log rows (terminated + // by .groupBy). Return each in order. + const mockDb = (billedRows: Array>) => { + let call = 0 + dbSelectMock.mockImplementation(() => { + call += 1 + const rows = call === 1 ? [{ id: 'workflow-1', workspaceId: 'ws-1' }] : billedRows + const chain: any = { + from: () => chain, + where: () => chain, + limit: () => Promise.resolve(rows), + groupBy: () => Promise.resolve(rows), + } + return chain + }) + } + + const run = ( + summary: ReturnType, + billedRows: Array> + ) => { + mockDb(billedRows) + return logger.recordExecutionUsage('workflow-1', summary, 'api', 'exec-1', 'user-1') + } + + const lastEntries = () => vi.mocked(recordUsage).mock.calls[0][0].entries + + test('fresh completion records all targets (base fee + model) and returns the increment', async () => { + const recorded = await run( + costSummary({ + models: { + 'gpt-4o': { + total: 1, + input: 0.6, + output: 0.4, + tokens: { input: 10, output: 5, total: 15 }, + }, + }, + }), + [] + ) + + expect(recordUsage).toHaveBeenCalledTimes(1) + expect(lastEntries()).toEqual([ + expect.objectContaining({ category: 'fixed', description: 'execution_fee', cost: 0.005 }), + expect.objectContaining({ category: 'model', description: 'gpt-4o', cost: 1 }), + ]) + // Returns the amount recorded at this boundary (drives threshold-email math). + expect(recorded).toBeCloseTo(1.005, 8) + // cost_total is refined to the exact ledger sum inside the locked tx. + expect(txUpdateMock).toHaveBeenCalledTimes(1) + }) + + test('resume records only the increment over what is already billed', async () => { + await run( + costSummary({ + models: { + 'gpt-4o': { + total: 3, + input: 1.8, + output: 1.2, + tokens: { input: 30, output: 15, total: 45 }, + }, + }, + }), + [ + { category: 'fixed', description: 'execution_fee', cost: '0.005' }, + { category: 'model', description: 'gpt-4o', cost: '1' }, + ] + ) + + expect(recordUsage).toHaveBeenCalledTimes(1) + const entries = lastEntries() + expect(entries).toHaveLength(1) + expect(entries[0]).toEqual( + expect.objectContaining({ category: 'model', description: 'gpt-4o', cost: 2 }) + ) + }) + + test('returns only the post-resume increment (not the cumulative total)', async () => { + const recorded = await run( + costSummary({ + models: { + 'gpt-4o': { + total: 3, + input: 0, + output: 0, + tokens: { input: 0, output: 0, total: 0 }, + }, + }, + }), + [ + { category: 'fixed', description: 'execution_fee', cost: '0.005' }, + { category: 'model', description: 'gpt-4o', cost: '1' }, + ] + ) + // Cumulative is 3.005, but only the $2 increment was recorded here — the + // threshold-email math must not re-count the pre-pause $1.005. + expect(recorded).toBe(2) + }) + + test('forwards a pre-resolved billing context to recordUsage (skips re-lookup)', async () => { + mockDb([]) + const billingContext = { + billingEntity: { type: 'user' as const, id: 'user-1' }, + billingPeriod: { start: new Date('2026-01-01'), end: new Date('2026-02-01') }, + } + await (logger as any).recordExecutionUsage( + 'workflow-1', + costSummary({ + models: { + 'gpt-4o': { + total: 1, + input: 0, + output: 0, + tokens: { input: 0, output: 0, total: 0 }, + }, + }, + }), + 'api', + 'exec-1', + 'user-1', + billingContext + ) + expect(vi.mocked(recordUsage).mock.calls[0][0]).toMatchObject({ + billingEntity: { type: 'user', id: 'user-1' }, + billingPeriod: billingContext.billingPeriod, + }) + }) + + test('retry with everything already billed records nothing (idempotent)', async () => { + await run( + costSummary({ + models: { + 'gpt-4o': { + total: 1, + input: 0.6, + output: 0.4, + tokens: { input: 10, output: 5, total: 15 }, + }, + }, + }), + [ + { category: 'fixed', description: 'execution_fee', cost: '0.005' }, + { category: 'model', description: 'gpt-4o', cost: '1' }, + ] + ) + + expect(recordUsage).not.toHaveBeenCalled() + }) + + test('BYOK run records only the base fee (no zero-cost model rows)', async () => { + await run(costSummary({ models: {}, charges: {} }), []) + + expect(recordUsage).toHaveBeenCalledTimes(1) + expect(lastEntries()).toEqual([ + expect.objectContaining({ category: 'fixed', description: 'execution_fee', cost: 0.005 }), + ]) + }) + + test('standalone hosted-tool charge reconciles as a tool row', async () => { + await run(costSummary({ charges: { 'Exa Search': { total: 0.02 } } }), [ + { category: 'fixed', description: 'execution_fee', cost: '0.005' }, + ]) + + expect(lastEntries()).toEqual([ + expect.objectContaining({ category: 'tool', description: 'Exa Search', cost: 0.02 }), + ]) + }) + + test('two boundaries (pause then resume) bill the full run exactly once', async () => { + const model = (total: number) => ({ + 'gpt-4o': { + input: total * 0.6, + output: total * 0.4, + total, + tokens: { input: 10, output: 5, total: 15 }, + }, + }) + + // Boundary 1 (pause): nothing billed yet, partial cost. + await run(costSummary({ models: model(1) }), []) + const firstEntries = vi.mocked(recordUsage).mock.calls[0][0].entries + + // Feed boundary 1's rows back as already-billed for boundary 2. + const billedAfterFirst = firstEntries.map((e: any) => ({ + category: e.category, + description: e.description, + cost: String(e.cost), + })) + + // Boundary 2 (resume terminal): same model, higher cumulative cost. + await run(costSummary({ models: model(3) }), billedAfterFirst) + const secondEntries = vi.mocked(recordUsage).mock.calls[1][0].entries + + const ledgerTotal = [...firstEntries, ...secondEntries].reduce( + (sum: number, e: any) => sum + e.cost, + 0 + ) + expect(ledgerTotal).toBeCloseTo(3.005, 8) // base 0.005 once + gpt-4o 3 total + // Base fee billed once (boundary 1 only); model increment only at boundary 2. + expect(firstEntries.some((e: any) => e.category === 'fixed')).toBe(true) + expect(secondEntries.some((e: any) => e.category === 'fixed')).toBe(false) + expect(secondEntries).toEqual([ + expect.objectContaining({ category: 'model', description: 'gpt-4o', cost: 2 }), + ]) + }) + + test('eventKey is scoped by billedBefore so cross-boundary increments do not collide', async () => { + const model = (total: number) => ({ + 'gpt-4o': { input: 0, output: 0, total, tokens: { input: 0, output: 0, total: 0 } }, + }) + + await run(costSummary({ models: model(1) }), []) + const key0 = vi + .mocked(recordUsage) + .mock.calls[0][0].entries.find((e: any) => e.category === 'model')?.eventKey + + await run(costSummary({ models: model(3) }), [ + { category: 'fixed', description: 'execution_fee', cost: '0.005' }, + { category: 'model', description: 'gpt-4o', cost: '1' }, + ]) + const key1 = vi + .mocked(recordUsage) + .mock.calls[1][0].entries.find((e: any) => e.category === 'model')?.eventKey + + expect(key0).toContain('"billedBefore":"0.00000000"') + expect(key1).toContain('"billedBefore":"1.00000000"') + expect(key0).not.toEqual(key1) + }) + + test('a decreased cumulative cost (negative delta) records nothing for that line', async () => { + await run( + costSummary({ + models: { + 'gpt-4o': { input: 0, output: 0, total: 3, tokens: { input: 0, output: 0, total: 0 } }, + }, + }), + [ + { category: 'fixed', description: 'execution_fee', cost: '0.005' }, + { category: 'model', description: 'gpt-4o', cost: '5' }, + ] + ) + expect(recordUsage).not.toHaveBeenCalled() + }) + + test('a model introduced only post-resume is billed in full; the already-billed model is skipped', async () => { + await run( + costSummary({ + models: { + 'gpt-4o': { input: 0, output: 0, total: 1, tokens: { input: 0, output: 0, total: 0 } }, + 'claude-3': { input: 0, output: 0, total: 2, tokens: { input: 0, output: 0, total: 0 } }, + }, + }), + [ + { category: 'fixed', description: 'execution_fee', cost: '0.005' }, + { category: 'model', description: 'gpt-4o', cost: '1' }, + ] + ) + expect(lastEntries()).toEqual([ + expect.objectContaining({ category: 'model', description: 'claude-3', cost: 2 }), + ]) + }) + + test('zero-cost models and charges (BYOK) are filtered out, leaving only the base fee', async () => { + await run( + costSummary({ + models: { + 'gpt-4o': { input: 0, output: 0, total: 0, tokens: { input: 0, output: 0, total: 0 } }, + }, + charges: { Exa: { total: 0 } }, + }), + [] + ) + expect(lastEntries()).toEqual([ + expect.objectContaining({ category: 'fixed', description: 'execution_fee', cost: 0.005 }), + ]) + }) + + test('reconciles inside a transaction holding a per-execution advisory lock', async () => { + await run( + costSummary({ + models: { + 'gpt-4o': { input: 0, output: 0, total: 1, tokens: { input: 0, output: 0, total: 0 } }, + }, + }), + [] + ) + + // set_config('lock_timeout') + pg_advisory_xact_lock both run on the tx. + expect(dbExecuteMock).toHaveBeenCalledTimes(2) + expect(recordUsage).toHaveBeenCalledTimes(1) + // The ledger INSERT participates in the locked transaction. + expect(vi.mocked(recordUsage).mock.calls[0][0]).toHaveProperty('tx') + }) +}) diff --git a/apps/sim/lib/logs/execution/logger.ts b/apps/sim/lib/logs/execution/logger.ts index e52b92f427..66abe6283a 100644 --- a/apps/sim/lib/logs/execution/logger.ts +++ b/apps/sim/lib/logs/execution/logger.ts @@ -1,6 +1,7 @@ import { db } from '@sim/db' import { member, + usageLog, userStats, user as userTable, workflow, @@ -9,15 +10,20 @@ import { 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' +import { and, eq, sql } from 'drizzle-orm' import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription' import { checkUsageStatus, getOrgUsageLimit, maybeSendUsageThresholdEmail, } from '@/lib/billing/core/usage' -import { type ModelUsageMetadata, recordUsage } from '@/lib/billing/core/usage-log' +import { + type BillingContext, + deriveBillingContext, + type ModelUsageMetadata, + recordUsage, + stableEventKey, +} from '@/lib/billing/core/usage-log' import { checkAndBillOverageThreshold } from '@/lib/billing/threshold-billing' import { isBillingEnabled } from '@/lib/core/config/feature-flags' import { redactApiKeys } from '@/lib/core/security/redaction' @@ -28,6 +34,7 @@ import { } from '@/lib/execution/payloads/large-value-metadata' import { emitWorkflowExecutionCompleted } from '@/lib/logs/events' import { snapshotService } from '@/lib/logs/execution/snapshot/service' +import { externalizeExecutionData, stripSpanCosts } from '@/lib/logs/execution/trace-store' import type { BlockOutputData, ExecutionEnvironment, @@ -48,6 +55,9 @@ const MAX_WORKFLOW_VALUE_BYTES = 512 * 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 +// Bounds the wait for the per-execution usage-reconcile advisory lock. Generous +// (favor waiting over dropping a charge); only trips on a pathological lock hold. +const USAGE_RECONCILE_LOCK_TIMEOUT_MS = 10_000 type ExecutionData = WorkflowExecutionLog['executionData'] @@ -548,13 +558,6 @@ export class ExecutionLogger implements IExecutionLoggerService { hasTraceSpans: false, traceSpanCount: 0, }, - cost: { - total: BASE_EXECUTION_CHARGE, - input: 0, - output: 0, - tokens: { input: 0, output: 0, total: 0 }, - models: {}, - }, }) .returning() @@ -590,7 +593,6 @@ export class ExecutionLogger implements IExecutionLoggerService { totalPromptTokens: number totalCompletionTokens: number baseExecutionCharge: number - modelCost: number models: Record< string, { @@ -601,6 +603,7 @@ export class ExecutionLogger implements IExecutionLoggerService { tokens: { input: number; output: number; total: number } } > + charges?: Record } finalOutput: BlockOutputData traceSpans?: TraceSpan[] @@ -732,7 +735,26 @@ export class ExecutionLogger implements IExecutionLoggerService { }, executionId ) - const completedExecutionLargeValueKeys = collectLargeValueReferenceKeys(completedExecutionData) + + stripSpanCosts((completedExecutionData as Record).traceSpans) + + // Externalization requires the execution owner (workspace_files.user_id is + // NOT NULL). billingUserId comes from environment.userId and is effectively + // always present for a real run; if it's somehow absent, keep data inline. + let storedExecutionData = completedExecutionData as Record + if (billingUserId) { + storedExecutionData = await externalizeExecutionData(storedExecutionData, { + workspaceId: existingLog?.workspaceId ?? null, + workflowId: existingLog?.workflowId ?? null, + executionId, + userId: billingUserId, + }) + } else { + execLog.warn('Skipping execution-data externalization: missing owner userId', { + executionId, + }) + } + const completedExecutionLargeValueKeys = collectLargeValueReferenceKeys(storedExecutionData) const updatedLog = await db.transaction(async (tx) => { await setExecutionLogWriteTimeouts(tx) @@ -745,8 +767,17 @@ export class ExecutionLogger implements IExecutionLoggerService { endedAt: new Date(endedAt), totalDurationMs: totalDuration, files: executionFiles.length > 0 ? executionFiles : null, - executionData: completedExecutionData, - cost: executionCost, + executionData: storedExecutionData, + // Faithful projection of the usage_log ledger. Neither cost_total nor + // models_used may regress below a prior boundary: a paused run that + // resumes into an empty-span error/cancel/cost-only fallback produces a + // base-only summary. GREATEST keeps the higher cumulative cost_total, + // and models_used is overwritten only when this boundary actually has + // models — so both stay == SUM(usage_log) on every monotonic path. + costTotal: sql`GREATEST(COALESCE(${workflowExecutionLogs.costTotal}, 0), ${costSummary.totalCost.toString()}::numeric)`, + ...(Object.keys(costSummary.models).length > 0 + ? { modelsUsed: Object.keys(costSummary.models) } + : {}), }) .where(eq(workflowExecutionLogs.executionId, executionId)) .returning() @@ -770,116 +801,146 @@ export class ExecutionLogger implements IExecutionLoggerService { }) try { - // Skip workflow lookup if workflow was deleted + // Skip workflow lookup if workflow was deleted. const wf = updatedLog.workflowId ? (await db.select().from(workflow).where(eq(workflow.id, updatedLog.workflowId)))[0] : undefined - if (wf && billingUserId) { - const [usr] = await db - .select({ id: userTable.id, email: userTable.email, name: userTable.name }) - .from(userTable) - .where(eq(userTable.id, billingUserId)) - .limit(1) - if (usr?.email) { - const sub = await getHighestPrioritySubscription(usr.id) + const usr = + wf && billingUserId + ? ( + await db + .select({ id: userTable.id, email: userTable.email, name: userTable.name }) + .from(userTable) + .where(eq(userTable.id, billingUserId)) + .limit(1) + )[0] + : undefined - const costDelta = costSummary.totalCost + // Resolve the billing context + the pre-increment usage snapshot for the + // threshold email BEFORE recording, so currentUsageAfter = before + + // costDelta doesn't double-count this boundary's own increment. + type EmailContext = + | { + scope: 'user' + userId: string + userEmail: string + userName: string | null + planName: string + before: Awaited> + } + | { + scope: 'organization' + organizationId: string + planName: string + orgLimit: number + orgUsageBefore: number + } + let billingContext: BillingContext | undefined + let emailContext: EmailContext | undefined - const { getDisplayPlanName } = await import('@/lib/billing/plan-helpers') - const { isOrgScopedSubscription } = await import('@/lib/billing/subscriptions/utils') - const planName = getDisplayPlanName(sub?.plan) - const scope: 'user' | 'organization' = isOrgScopedSubscription(sub, usr.id) - ? 'organization' - : 'user' + if (usr?.email) { + const sub = await getHighestPrioritySubscription(usr.id) + // Derive the billing context once from the subscription we just fetched + // and thread it into recordExecutionUsage so recordUsage doesn't + // re-resolve the subscription on the hot completion path. + billingContext = deriveBillingContext(usr.id, sub) - if (scope === 'user') { - const before = await checkUsageStatus(usr.id) + const { getDisplayPlanName } = await import('@/lib/billing/plan-helpers') + const { isOrgScopedSubscription } = await import('@/lib/billing/subscriptions/utils') + const planName = getDisplayPlanName(sub?.plan) - await this.updateUserStats( - updatedLog.workflowId, - costSummary, - updatedLog.trigger as ExecutionTrigger['type'], - executionId, - billingUserId - ) - - const limit = before.usageData.limit - const percentBefore = before.usageData.percentUsed - const percentAfter = - limit > 0 ? Math.min(100, percentBefore + (costDelta / limit) * 100) : percentBefore - const currentUsageAfter = before.usageData.currentUsage + costDelta - - await maybeSendUsageThresholdEmail({ - scope: 'user', - userId: usr.id, - userEmail: usr.email, - userName: usr.name || undefined, - planName, - percentBefore, - percentAfter, - currentUsageAfter, - limit, - }) - } else if (sub?.referenceId) { - // Get org usage limit using shared helper - const { limit: orgLimit } = await getOrgUsageLimit(sub.referenceId, sub.plan, sub.seats) - - const [{ sum: orgUsageBefore }] = await db - .select({ sum: sql`COALESCE(SUM(${userStats.currentPeriodCost}), 0)` }) - .from(member) - .leftJoin(userStats, eq(member.userId, userStats.userId)) - .where(eq(member.organizationId, sub.referenceId)) - .limit(1) - const orgUsageBeforeNum = Number.parseFloat(String(orgUsageBefore ?? '0')) - - await this.updateUserStats( - updatedLog.workflowId, - costSummary, - updatedLog.trigger as ExecutionTrigger['type'], - executionId, - billingUserId - ) - - const percentBefore = - orgLimit > 0 ? Math.min(100, (orgUsageBeforeNum / orgLimit) * 100) : 0 - const percentAfter = - orgLimit > 0 - ? Math.min(100, percentBefore + (costDelta / orgLimit) * 100) - : percentBefore - const currentUsageAfter = orgUsageBeforeNum + costDelta - - await maybeSendUsageThresholdEmail({ - scope: 'organization', - organizationId: sub.referenceId, - planName, - percentBefore, - percentAfter, - currentUsageAfter, - limit: orgLimit, - }) + if (isOrgScopedSubscription(sub, usr.id) && sub?.referenceId) { + const { limit: orgLimit } = await getOrgUsageLimit(sub.referenceId, sub.plan, sub.seats) + const [{ sum: orgBaselineSum }] = await db + .select({ sum: sql`COALESCE(SUM(${userStats.currentPeriodCost}), 0)` }) + .from(member) + .leftJoin(userStats, eq(member.userId, userStats.userId)) + .where(eq(member.organizationId, sub.referenceId)) + .limit(1) + // currentPeriodCost is only a baseline; add the org's attributed + // usage_log for the period so the threshold email reflects real usage. + const { getBillingPeriodUsageCost } = await import('@/lib/billing/core/usage-log') + const orgLedger = + sub.periodStart && sub.periodEnd + ? await getBillingPeriodUsageCost( + { type: 'organization', id: sub.referenceId }, + { start: sub.periodStart, end: sub.periodEnd } + ) + : 0 + emailContext = { + scope: 'organization', + organizationId: sub.referenceId, + planName, + orgLimit, + orgUsageBefore: Number.parseFloat(String(orgBaselineSum ?? '0')) + orgLedger, } } else { - await this.updateUserStats( - updatedLog.workflowId, - costSummary, - updatedLog.trigger as ExecutionTrigger['type'], - executionId, - billingUserId - ) + emailContext = { + scope: 'user', + userId: usr.id, + userEmail: usr.email, + userName: usr.name, + planName, + before: await checkUsageStatus(usr.id), + } } - } else { - await this.updateUserStats( - updatedLog.workflowId, - costSummary, - updatedLog.trigger as ExecutionTrigger['type'], - executionId, - billingUserId - ) + } + + // Record usage exactly once for every path. costDelta is the amount + // actually recorded at this boundary (the increment), not the cumulative + // run total — so resumed runs don't double-count pre-pause cost below. + const costDelta = await this.recordExecutionUsage( + updatedLog.workflowId, + costSummary, + updatedLog.trigger as ExecutionTrigger['type'], + executionId, + billingUserId, + billingContext + ) + + // Best-effort usage-threshold email. + if (emailContext?.scope === 'user') { + const limit = emailContext.before.usageData.limit + const percentBefore = emailContext.before.usageData.percentUsed + const percentAfter = + limit > 0 ? Math.min(100, percentBefore + (costDelta / limit) * 100) : percentBefore + const currentUsageAfter = emailContext.before.usageData.currentUsage + costDelta + + await maybeSendUsageThresholdEmail({ + scope: 'user', + userId: emailContext.userId, + userEmail: emailContext.userEmail, + userName: emailContext.userName || undefined, + planName: emailContext.planName, + percentBefore, + percentAfter, + currentUsageAfter, + limit, + }) + } else if (emailContext?.scope === 'organization') { + const { orgLimit, orgUsageBefore } = emailContext + const percentBefore = orgLimit > 0 ? Math.min(100, (orgUsageBefore / orgLimit) * 100) : 0 + const percentAfter = + orgLimit > 0 ? Math.min(100, percentBefore + (costDelta / orgLimit) * 100) : percentBefore + const currentUsageAfter = orgUsageBefore + costDelta + + await maybeSendUsageThresholdEmail({ + scope: 'organization', + organizationId: emailContext.organizationId, + planName: emailContext.planName, + percentBefore, + percentAfter, + currentUsageAfter, + limit: orgLimit, + }) } } catch (e) { + // Safety net: if a step above threw BEFORE the single record call, ensure + // the run is still billed. Reconciliation is idempotent, so re-recording + // after a successful call is a no-op. try { - await this.updateUserStats( + await this.recordExecutionUsage( updatedLog.workflowId, costSummary, updatedLog.trigger as ExecutionTrigger['type'], @@ -902,8 +963,13 @@ export class ExecutionLogger implements IExecutionLoggerService { startedAt: updatedLog.startedAt.toISOString(), endedAt: updatedLog.endedAt?.toISOString() || endedAt, totalDurationMs: updatedLog.totalDurationMs || totalDurationMs, - executionData: updatedLog.executionData as WorkflowExecutionLog['executionData'], - cost: updatedLog.cost as WorkflowExecutionLog['cost'], + // Return the full in-memory execution data (cost-stripped, with traceSpans + // and finalOutput), not the slim externalized row — downstream consumers + // (notification delivery, events) need the complete payload without an + // extra storage round-trip. + executionData: completedExecutionData as WorkflowExecutionLog['executionData'], + // From the in-memory cost summary (not the deprecated cost jsonb column). + cost: executionCost as WorkflowExecutionLog['cost'], createdAt: updatedLog.createdAt.toISOString(), } @@ -934,7 +1000,10 @@ export class ExecutionLogger implements IExecutionLoggerService { endedAt: workflowLog.endedAt?.toISOString() || workflowLog.startedAt.toISOString(), totalDurationMs: workflowLog.totalDurationMs || 0, executionData: workflowLog.executionData as WorkflowExecutionLog['executionData'], - cost: workflowLog.cost as WorkflowExecutionLog['cost'], + // cost_total projection of the usage_log ledger (not the deprecated jsonb). + cost: (workflowLog.costTotal != null + ? { total: Number(workflowLog.costTotal) } + : null) as WorkflowExecutionLog['cost'], createdAt: workflowLog.createdAt.toISOString(), } } @@ -959,7 +1028,7 @@ export class ExecutionLogger implements IExecutionLoggerService { return trimmedUserId.length > 0 ? trimmedUserId : null } - private async updateUserStats( + private async recordExecutionUsage( workflowId: string | null, costSummary: { totalCost: number @@ -969,7 +1038,6 @@ export class ExecutionLogger implements IExecutionLoggerService { totalPromptTokens: number totalCompletionTokens: number baseExecutionCharge: number - modelCost: number models?: Record< string, { @@ -980,28 +1048,31 @@ export class ExecutionLogger implements IExecutionLoggerService { tokens: { input: number; output: number; total: number } } > + charges?: Record }, trigger: ExecutionTrigger['type'], executionId?: string, - billingUserId?: string | null - ): Promise { + billingUserId?: string | null, + // Pre-resolved billing context. The completion path already fetches the + // subscription for usage-threshold emails; passing the derived context here + // lets recordUsage skip a redundant subscription lookup per completion. + billingContext?: BillingContext + ): Promise { const statsLog = logger.withMetadata({ workflowId: workflowId ?? undefined, executionId }) - if (!isBillingEnabled) { - statsLog.debug('Billing is disabled, skipping user stats cost update') - return - } - - if (costSummary.totalCost <= 0) { - statsLog.debug('No cost to update in user stats') - return - } + // The usage ledger (recordUsage below) is written regardless of + // BILLING_ENABLED so cost is available everywhere (incl. self-hosted). + // Only enforcement (overage/Stripe) is gated on the flag. + // Returns the amount actually recorded at THIS boundary (the increment), so + // callers drive usage-threshold math off the delta rather than the + // cumulative run total (which would double-count pre-pause cost on resume). if (!workflowId) { - statsLog.debug('Workflow was deleted, skipping user stats update') - return + statsLog.debug('Workflow was deleted, skipping usage recording') + return 0 } + let recordedIncrement = 0 try { const [workflowRecord] = await db .select() @@ -1010,43 +1081,47 @@ export class ExecutionLogger implements IExecutionLoggerService { .limit(1) if (!workflowRecord) { - statsLog.error('Workflow not found for user stats update') - return + statsLog.error('Workflow not found for usage recording') + return 0 } const userId = billingUserId?.trim() || null if (!userId) { - statsLog.error('Missing billing actor in execution context; skipping stats update', { + statsLog.error('Missing billing actor in execution context; skipping usage recording', { trigger, }) - return + return 0 } - const entries: Array<{ - category: 'model' | 'fixed' - source: 'workflow' + // Build the run's *cumulative* target ledger lines from the cost summary. + // The usage_log is then reconciled to these targets: at each completion + // boundary (pause or terminal) we record only the increment versus what + // is already billed for this execution. This bills the full run exactly + // once across pause/resume without double-charging on resume, and keeps + // pre-pause work billed even if the run is later abandoned. + type TargetLine = { + category: 'model' | 'fixed' | 'tool' description: string - cost: number + target: number metadata?: ModelUsageMetadata | null - }> = [] + } + const targets: TargetLine[] = [] if (costSummary.baseExecutionCharge > 0) { - entries.push({ + targets.push({ category: 'fixed', - source: 'workflow', description: 'execution_fee', - cost: costSummary.baseExecutionCharge, + target: costSummary.baseExecutionCharge, }) } if (costSummary.models) { for (const [modelName, modelData] of Object.entries(costSummary.models)) { if (modelData.total > 0) { - entries.push({ + targets.push({ category: 'model', - source: 'workflow', description: modelName, - cost: modelData.total, + target: modelData.total, metadata: { inputTokens: modelData.tokens.input, outputTokens: modelData.tokens.output, @@ -1058,23 +1133,173 @@ export class ExecutionLogger implements IExecutionLoggerService { } } - await recordUsage({ - userId, - entries, - workspaceId: workflowRecord.workspaceId ?? undefined, - workflowId, - executionId, - }) + // Non-model billable charges (standalone hosted-key tool/integration + // blocks). These derive from already-gated span costs in + // calculateCostSummary — BYOK'd tools produce no cost upstream, so they + // never create a row here. Recording them closes the standalone-tool gap + // so the ledger fully reconciles with the run total (no double charge: + // agent-embedded tool cost stays folded into its model row). + if (costSummary.charges) { + for (const [description, charge] of Object.entries(costSummary.charges)) { + if (charge.total > 0) { + targets.push({ category: 'tool', description, target: charge.total }) + } + } + } - // Check if user has hit overage threshold and bill incrementally - await checkAndBillOverageThreshold(userId) + if (targets.length === 0) { + statsLog.debug('No cost to record') + return 0 + } + + // Matches the billedBefore key resolution (toFixed(8)): a delta below this + // is finer than the idempotency key can distinguish across boundaries, so + // ignoring it keeps the key and the gate consistent. + const COST_EPSILON = 1e-8 + + // Build the positive-increment ledger entries for a given already-billed + // snapshot. The eventKey is scoped by the already-billed-so-far amount so + // increments across boundaries never collide, while a retried boundary + // (same already-billed) dedups via onConflictDoNothing. + // + // Reconciliation keys on `description` (model name / tool name), which is + // the billing identity — DO NOT normalize or relabel it. Correctness + // across pause/resume relies on the same usage carrying the same + // description at every boundary; the paused snapshot retains each block's + // original model label, so pre-pause cost stays under its original key + // (delta 0 at terminal) and only genuinely new usage is charged. A future + // change that relabels historical spans would break this invariant. + const buildDeltaEntries = (alreadyBilled: Map) => { + const entries: Array<{ + category: 'model' | 'fixed' | 'tool' + source: 'workflow' + description: string + cost: number + eventKey: string + metadata?: ModelUsageMetadata | null + }> = [] + for (const line of targets) { + const billed = alreadyBilled.get(`${line.category}::${line.description}`) ?? 0 + const delta = line.target - billed + if (delta <= COST_EPSILON) continue + entries.push({ + category: line.category, + source: 'workflow', + description: line.description, + cost: delta, + eventKey: stableEventKey({ + executionId: executionId ?? '', + category: line.category, + description: line.description, + billedBefore: billed.toFixed(8), + }), + ...(line.metadata !== undefined ? { metadata: line.metadata } : {}), + }) + } + return entries + } + + if (executionId) { + // Serialize concurrent completion boundaries for this execution so the + // read-then-insert reconciliation cannot race. pg_advisory_xact_lock is + // transaction-scoped (auto-released on commit/rollback, pool-safe) and + // bounded by lock_timeout. The critical section is one SELECT + one + // INSERT; the lock is uncontended in the normal (already-serialized) + // flow and only matters under a cross-process double-completion of the + // same execution, where it stops a stale already-billed read from + // dropping the larger delta. + await db.transaction(async (tx) => { + await tx.execute( + sql`select set_config('lock_timeout', ${`${USAGE_RECONCILE_LOCK_TIMEOUT_MS}ms`}, true)` + ) + await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${executionId}, 0))`) + + // Already-billed for this execution, scoped to the rows this path owns + // (source='workflow') so a same-executionId row from another source + // can't suppress a charge. + const billedRows = await tx + .select({ + category: usageLog.category, + description: usageLog.description, + cost: sql`COALESCE(SUM(${usageLog.cost}), 0)`, + }) + .from(usageLog) + .where(and(eq(usageLog.executionId, executionId), eq(usageLog.source, 'workflow'))) + .groupBy(usageLog.category, usageLog.description) + + const alreadyBilled = new Map() + for (const row of billedRows) { + alreadyBilled.set( + `${row.category}::${row.description}`, + Number.parseFloat(row.cost ?? '0') + ) + } + + const entries = buildDeltaEntries(alreadyBilled) + if (entries.length > 0) { + await recordUsage({ + userId, + entries, + workspaceId: workflowRecord.workspaceId ?? undefined, + workflowId, + executionId, + tx, + ...(billingContext ?? {}), + }) + recordedIncrement = entries.reduce((acc, e) => acc + e.cost, 0) + + // Refine cost_total to the EXACT post-reconciliation ledger sum, + // inside the same advisory-locked tx so it is atomic with the inserts + // and can't be clobbered by a concurrent boundary. Exact by + // construction: under the lock no delta collides, so the new sum is + // the prior workflow-source sum plus the deltas just inserted. This + // supersedes the main-transaction GREATEST baseline (which remains for + // early-return / no-executionId / failed-reconcile paths). + const ledgerSum = + [...alreadyBilled.values()].reduce((acc, v) => acc + v, 0) + recordedIncrement + await tx + .update(workflowExecutionLogs) + .set({ costTotal: ledgerSum.toString() }) + .where(eq(workflowExecutionLogs.executionId, executionId)) + } + }) + } else { + // No execution scope to reconcile/lock against (not expected at a + // workflow completion): record the full targets directly. + const entries = buildDeltaEntries(new Map()) + if (entries.length > 0) { + await recordUsage({ + userId, + entries, + workspaceId: workflowRecord.workspaceId ?? undefined, + workflowId, + ...(billingContext ?? {}), + }) + recordedIncrement = entries.reduce((acc, e) => acc + e.cost, 0) + } + } + + // Enforcement only when billing is enabled: the ledger above is always + // written, but overage/Stripe billing is gated on BILLING_ENABLED. + if (isBillingEnabled) { + await checkAndBillOverageThreshold(userId) + } } catch (error) { - statsLog.error('Error updating user stats with cost information', { - error, - costSummary, - }) - // Don't throw - we want execution to continue even if user stats update fails + // Swallowed so a billing-write failure never fails the execution. The + // reconciliation self-heals on a later boundary; a TERMINAL-boundary + // failure leaves the run under-billed (and cost_total may then exceed + // SUM(usage_log)), so log loudly enough to alert / reconcile out of band. + statsLog.error( + 'Failed to record execution usage to usage_log ledger; charge may be unbilled', + { + error, + billingUserId, + costSummary, + } + ) } + + return recordedIncrement } /** diff --git a/apps/sim/lib/logs/execution/logging-factory.test.ts b/apps/sim/lib/logs/execution/logging-factory.test.ts index 27194c4d91..6260165077 100644 --- a/apps/sim/lib/logs/execution/logging-factory.test.ts +++ b/apps/sim/lib/logs/execution/logging-factory.test.ts @@ -150,7 +150,6 @@ describe('calculateCostSummary', () => { expect(result.totalCost).toBe(BASE_EXECUTION_CHARGE) expect(result.baseExecutionCharge).toBe(BASE_EXECUTION_CHARGE) - expect(result.modelCost).toBe(0) expect(result.totalInputCost).toBe(0) expect(result.totalOutputCost).toBe(0) expect(result.totalTokens).toBe(0) @@ -188,7 +187,6 @@ describe('calculateCostSummary', () => { const result = calculateCostSummary(traceSpans) expect(result.totalCost).toBe(0.03 + BASE_EXECUTION_CHARGE) - expect(result.modelCost).toBe(0.03) expect(result.totalInputCost).toBe(0.01) expect(result.totalOutputCost).toBe(0.02) expect(result.totalTokens).toBe(300) @@ -221,7 +219,6 @@ describe('calculateCostSummary', () => { const result = calculateCostSummary(traceSpans) expect(result.totalCost).toBe(0.033 + BASE_EXECUTION_CHARGE) - expect(result.modelCost).toBe(0.033) expect(result.totalInputCost).toBe(0.011) expect(result.totalOutputCost).toBe(0.022) expect(result.totalTokens).toBe(450) @@ -280,7 +277,6 @@ describe('calculateCostSummary', () => { const result = calculateCostSummary(traceSpans) - expect(result.modelCost).toBe(0.03) expect(result.totalCost).toBe(0.03 + BASE_EXECUTION_CHARGE) expect(result.models['claude-3']).toBeDefined() expect(result.models['claude-3'].total).toBe(0.03) @@ -308,7 +304,6 @@ describe('calculateCostSummary', () => { const result = calculateCostSummary(traceSpans) - expect(result.modelCost).toBe(0.03) expect(result.models['gpt-4']).toBeDefined() }) @@ -345,7 +340,6 @@ describe('calculateCostSummary', () => { const result = calculateCostSummary(traceSpans) - expect(result.modelCost).toBe(0.03) expect(Object.keys(result.models)).toHaveLength(1) }) @@ -361,7 +355,6 @@ describe('calculateCostSummary', () => { const result = calculateCostSummary(traceSpans) - expect(result.modelCost).toBe(0.03) expect(result.totalCost).toBe(0.03 + BASE_EXECUTION_CHARGE) // Should not add to models if model is not specified expect(Object.keys(result.models)).toHaveLength(0) @@ -427,7 +420,6 @@ describe('calculateCostSummary', () => { const result = calculateCostSummary(traceSpans) - expect(result.modelCost).toBe(0) expect(result.totalCost).toBe(BASE_EXECUTION_CHARGE) // Model is still tracked for token-usage display, but cost must be zero. expect(result.models['claude-opus-4-6'].total).toBe(0) @@ -462,7 +454,6 @@ describe('calculateCostSummary', () => { const result = calculateCostSummary(traceSpans) - expect(result.modelCost).toBe(0.03) expect(result.totalCost).toBe(0.03 + BASE_EXECUTION_CHARGE) expect(result.models['gpt-4o'].total).toBe(0.03) }) @@ -494,9 +485,95 @@ describe('calculateCostSummary', () => { const result = calculateCostSummary(traceSpans) - expect(result.modelCost).toBe(0.045) expect(result.totalCost).toBe(0.045 + BASE_EXECUTION_CHARGE) expect(result.models['gpt-4o'].total).toBe(0.045) expect(result.models['gpt-4o'].toolCost).toBe(0.015) }) + + test('records a standalone non-model billable span as a charge (closes the tool gap)', () => { + const traceSpans = [ + { + id: 'exa-block', + name: 'Exa Search', + type: 'tool', + cost: { input: 0, output: 0, total: 0.01 }, + }, + ] + + const result = calculateCostSummary(traceSpans) + + expect(result.charges['Exa Search']).toBeDefined() + expect(result.charges['Exa Search'].total).toBe(0.01) + expect(Object.keys(result.models)).toHaveLength(0) + // Ledger partition reconciles with the run total. + const ledgerSum = + result.baseExecutionCharge + + Object.values(result.models).reduce((s, m) => s + m.total, 0) + + Object.values(result.charges).reduce((s, c) => s + c.total, 0) + expect(ledgerSum).toBeCloseTo(result.totalCost, 10) + }) + + test('does not double-count: agent-embedded tool stays in the model row, not charges', () => { + const traceSpans = [ + { + id: 'agent-span', + name: 'Agent', + type: 'agent', + model: 'gpt-4o', + cost: { input: 0.01, output: 0.02, total: 0.045, toolCost: 0.015 }, + tokens: { input: 1000, output: 2000, total: 3000 }, + }, + ] + + const result = calculateCostSummary(traceSpans) + + expect(Object.keys(result.charges)).toHaveLength(0) + expect(result.models['gpt-4o'].total).toBe(0.045) + expect(result.models['gpt-4o'].toolCost).toBe(0.015) + }) + + test('mixed model + standalone tool run reconciles to total', () => { + const traceSpans = [ + { + id: 'agent', + name: 'Agent', + type: 'agent', + model: 'gpt-4o', + cost: { input: 0.01, output: 0.02, total: 0.03 }, + tokens: { input: 100, output: 200, total: 300 }, + }, + { + id: 'exa', + name: 'Exa Search', + type: 'tool', + cost: { input: 0, output: 0, total: 0.01 }, + }, + ] + + const result = calculateCostSummary(traceSpans) + + expect(result.models['gpt-4o'].total).toBe(0.03) + expect(result.charges['Exa Search'].total).toBe(0.01) + const ledgerSum = + result.baseExecutionCharge + + Object.values(result.models).reduce((s, m) => s + m.total, 0) + + Object.values(result.charges).reduce((s, c) => s + c.total, 0) + expect(ledgerSum).toBeCloseTo(result.totalCost, 10) + }) + + test('BYOK tool (no cost generated upstream) produces no charge row', () => { + const traceSpans = [ + { + id: 'exa-byok', + name: 'Exa Search', + type: 'tool', + cost: { input: 0, output: 0, total: 0 }, + }, + ] + + const result = calculateCostSummary(traceSpans) + + expect(Object.keys(result.charges)).toHaveLength(0) + expect(result.totalCost).toBe(BASE_EXECUTION_CHARGE) + }) }) diff --git a/apps/sim/lib/logs/execution/logging-factory.ts b/apps/sim/lib/logs/execution/logging-factory.ts index 9011a39189..33582faab0 100644 --- a/apps/sim/lib/logs/execution/logging-factory.ts +++ b/apps/sim/lib/logs/execution/logging-factory.ts @@ -87,9 +87,41 @@ export async function loadDeployedWorkflowStateForLogging( type CostTraceSpan = Pick & { type?: TraceSpan['type'] + name?: TraceSpan['name'] children?: CostTraceSpan[] } +export interface CostSummaryModel { + input: number + output: number + total: number + toolCost?: number + tokens: { input: number; output: number; total: number } +} + +/** + * Non-model billable charge (e.g. a standalone hosted-key tool block such as + * Exa/Tavily/falai run outside an agent). These spans contribute to the run's + * total cost but carry no `model`, so they live here rather than in `models`. + * Summed per span name so the ledger has one row per integration. + */ +export interface CostSummaryCharge { + total: number +} + +export interface CostSummary { + totalCost: number + totalInputCost: number + totalOutputCost: number + totalTokens: number + totalPromptTokens: number + totalCompletionTokens: number + baseExecutionCharge: number + models: Record + /** Non-model billable charges keyed by span name (tool/integration costs). */ + charges: Record +} + type BillableTraceSpan = CostTraceSpan & { cost: NonNullable } function hasBillableCost(span: CostTraceSpan): span is BillableTraceSpan { @@ -100,26 +132,7 @@ function isModelBreakdownSpan(span: CostTraceSpan): boolean { return span.type === 'model' } -export function calculateCostSummary(traceSpans: CostTraceSpan[] | undefined): { - totalCost: number - totalInputCost: number - totalOutputCost: number - totalTokens: number - totalPromptTokens: number - totalCompletionTokens: number - baseExecutionCharge: number - modelCost: number - models: Record< - string, - { - input: number - output: number - total: number - toolCost?: number - tokens: { input: number; output: number; total: number } - } - > -} { +export function calculateCostSummary(traceSpans: CostTraceSpan[] | undefined): CostSummary { if (!traceSpans || traceSpans.length === 0) { return { totalCost: BASE_EXECUTION_CHARGE, @@ -129,8 +142,8 @@ export function calculateCostSummary(traceSpans: CostTraceSpan[] | undefined): { totalPromptTokens: 0, totalCompletionTokens: 0, baseExecutionCharge: BASE_EXECUTION_CHARGE, - modelCost: 0, models: {}, + charges: {}, } } @@ -181,16 +194,8 @@ export function calculateCostSummary(traceSpans: CostTraceSpan[] | undefined): { let totalTokens = 0 let totalPromptTokens = 0 let totalCompletionTokens = 0 - const models: Record< - string, - { - input: number - output: number - total: number - toolCost?: number - tokens: { input: number; output: number; total: number } - } - > = {} + const models: Record = {} + const charges: Record = {} for (const span of costSpans) { totalCost += span.cost.total || 0 @@ -220,10 +225,19 @@ export function calculateCostSummary(traceSpans: CostTraceSpan[] | undefined): { if (span.cost.toolCost) { models[model].toolCost = (models[model].toolCost || 0) + span.cost.toolCost } + } else if ((span.cost.total || 0) > 0) { + // Non-model billable span (e.g. a standalone hosted-key tool block). + // These previously contributed to the run total but were never itemized + // in the ledger (the "standalone tool gap"). Key by span name so each + // integration gets a single, reconciling charge row. + const description = span.name || span.type || 'tool' + if (!charges[description]) { + charges[description] = { total: 0 } + } + charges[description].total += span.cost.total || 0 } } - const modelCost = totalCost totalCost += BASE_EXECUTION_CHARGE return { @@ -234,7 +248,7 @@ export function calculateCostSummary(traceSpans: CostTraceSpan[] | undefined): { totalPromptTokens, totalCompletionTokens, baseExecutionCharge: BASE_EXECUTION_CHARGE, - modelCost, models, + charges, } } diff --git a/apps/sim/lib/logs/execution/logging-session.test.ts b/apps/sim/lib/logs/execution/logging-session.test.ts index bdd338e1ff..7ad138d0f9 100644 --- a/apps/sim/lib/logs/execution/logging-session.test.ts +++ b/apps/sim/lib/logs/execution/logging-session.test.ts @@ -75,7 +75,6 @@ vi.mock('@/lib/logs/execution/logging-factory', () => ({ totalPromptTokens: 0, totalCompletionTokens: 0, baseExecutionCharge: 0, - modelCost: 0, models: {}, }), createEnvironmentObject: vi.fn(), @@ -84,6 +83,7 @@ vi.mock('@/lib/logs/execution/logging-factory', () => ({ loadWorkflowStateForExecution: loadWorkflowStateForExecutionMock, })) +import { calculateCostSummary } from '@/lib/logs/execution/logging-factory' import { LoggingSession } from './logging-session' describe('LoggingSession start snapshots', () => { @@ -238,34 +238,54 @@ describe('LoggingSession completion retries', () => { ) }) - it('preserves accumulated cost during fallback completion', async () => { + it('derives fallback cost from trace spans when the primary completion fails', async () => { const session = new LoggingSession('workflow-1', 'execution-6', 'api', 'req-1') as any - session.accumulatedCost = { - total: 12, - input: 5, - output: 7, - tokens: { input: 11, output: 13, total: 24 }, - models: { - 'test-model': { - input: 5, - output: 7, - total: 12, - tokens: { input: 11, output: 13, total: 24 }, - }, - }, + // Resume-accumulation is retired: the cost-only fallback now derives its + // cost summary from the in-memory trace spans (billing itself reconciles + // from the usage_log ledger in recordExecutionUsage). The primary complete() + // path consumes one calculateCostSummary call before it fails, so queue the + // same value twice (primary attempt + fallback). + const spanCostSummary = { + totalCost: 12, + totalInputCost: 5, + totalOutputCost: 7, + totalTokens: 24, + totalPromptTokens: 11, + totalCompletionTokens: 13, + baseExecutionCharge: 0, + models: {}, + charges: {}, } - session.costFlushed = true + vi.mocked(calculateCostSummary) + .mockReturnValueOnce(spanCostSummary) + .mockReturnValueOnce(spanCostSummary) completeWorkflowExecutionMock .mockRejectedValueOnce(new Error('success finalize failed')) .mockResolvedValueOnce({}) - await expect(session.safeComplete({ finalOutput: { ok: true } })).resolves.toBeUndefined() + const traceSpans = [ + { + id: 'span-1', + name: 'Block A', + type: 'tool', + duration: 25, + startTime: '2026-03-13T10:00:00.000Z', + endTime: '2026-03-13T10:00:00.025Z', + status: 'success', + }, + ] as any + await expect( + session.safeComplete({ finalOutput: { ok: true }, traceSpans }) + ).resolves.toBeUndefined() + + expect(calculateCostSummary).toHaveBeenLastCalledWith(traceSpans) expect(completeWorkflowExecutionMock).toHaveBeenLastCalledWith( expect.objectContaining({ executionId: 'execution-6', + finalizationPath: 'fallback_completed', costSummary: expect.objectContaining({ totalCost: 12, totalInputCost: 5, @@ -440,24 +460,21 @@ describe('LoggingSession completion retries', () => { expect(session.complete).toHaveBeenCalledTimes(1) }) - it('drains fire-and-forget cost flushes before terminal completion', async () => { - let releaseFlush: (() => void) | undefined - const flushPromise = new Promise((resolve) => { - releaseFlush = resolve + it('drains fire-and-forget block-complete marker writes before terminal completion', async () => { + let releasePersist: (() => void) | undefined + const persistPromise = new Promise((resolve) => { + releasePersist = resolve }) const session = new LoggingSession('workflow-1', 'execution-1', 'api', 'req-1') as any - session.flushAccumulatedCost = vi.fn(() => flushPromise) + session.persistLastCompletedBlock = vi.fn(() => persistPromise) session.complete = vi.fn().mockResolvedValue(undefined) - await session.onBlockComplete('block-2', 'Transform', 'function', { + // onBlockComplete is now marker-only; its marker write is fire-and-forget + // but tracked, so terminal completion must drain it first. + void session.onBlockComplete('block-2', 'Transform', 'function', { endedAt: '2025-01-01T00:00:01.000Z', - output: { - value: true, - cost: { total: 1, input: 1, output: 0 }, - tokens: { input: 1, output: 0, total: 1 }, - model: 'test-model', - }, + output: { value: true }, }) const completionPromise = session.safeComplete({ finalOutput: { ok: true } }) @@ -466,11 +483,11 @@ describe('LoggingSession completion retries', () => { expect(session.complete).not.toHaveBeenCalled() - releaseFlush?.() + releasePersist?.() await completionPromise - expect(session.flushAccumulatedCost).toHaveBeenCalledTimes(1) + expect(session.persistLastCompletedBlock).toHaveBeenCalledTimes(1) expect(session.complete).toHaveBeenCalledTimes(1) }) diff --git a/apps/sim/lib/logs/execution/logging-session.ts b/apps/sim/lib/logs/execution/logging-session.ts index 56eb463e20..09bfd2348c 100644 --- a/apps/sim/lib/logs/execution/logging-session.ts +++ b/apps/sim/lib/logs/execution/logging-session.ts @@ -3,7 +3,6 @@ import { workflowExecutionLogs } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { and, eq, sql } from 'drizzle-orm' -import { BASE_EXECUTION_CHARGE } from '@/lib/billing/constants' import { executionLogger } from '@/lib/logs/execution/logger' import { calculateCostSummary, @@ -118,22 +117,6 @@ export interface SessionPausedParams { workflowInput?: any } -interface AccumulatedCost { - total: number - input: number - output: number - tokens: { input: number; output: number; total: number } - models: Record< - string, - { - input: number - output: number - total: number - tokens: { input: number; output: number; total: number } - } - > -} - export class LoggingSession { private workflowId: string private executionId: string @@ -151,15 +134,7 @@ export class LoggingSession { private completionPromise: Promise | null = null private completionAttempt: CompletionAttempt | null = null private completionAttemptFailed = false - private accumulatedCost: AccumulatedCost = { - total: BASE_EXECUTION_CHARGE, - input: 0, - output: 0, - tokens: { input: 0, output: 0, total: 0 }, - models: {}, - } private pendingProgressWrites = new Set>() - private costFlushed = false private postExecutionPromise: Promise | null = null constructor( @@ -249,7 +224,6 @@ export class LoggingSession { totalPromptTokens: number totalCompletionTokens: number baseExecutionCharge: number - modelCost: number models: Record< string, { @@ -259,6 +233,9 @@ export class LoggingSession { tokens: { input: number; output: number; total: number } } > + // Non-model billable charges (standalone tool/integration costs). Carried + // through so the partition can't be silently dropped at this boundary. + charges?: Record } finalOutput: Record traceSpans: TraceSpan[] @@ -292,46 +269,9 @@ export class LoggingSession { blockType: string, output: any ): Promise { - // Accumulate cost synchronously before any await so that fire-and-forget - // callers still capture the full cost even if DB writes are not awaited. - const blockOutput = output?.output - if ( - blockOutput?.cost && - typeof blockOutput.cost.total === 'number' && - blockOutput.cost.total > 0 - ) { - const { cost, tokens, model } = blockOutput - - this.accumulatedCost.total += cost.total || 0 - this.accumulatedCost.input += cost.input || 0 - this.accumulatedCost.output += cost.output || 0 - - if (tokens) { - this.accumulatedCost.tokens.input += tokens.input || 0 - this.accumulatedCost.tokens.output += tokens.output || 0 - this.accumulatedCost.tokens.total += tokens.total || 0 - } - - if (model) { - if (!this.accumulatedCost.models[model]) { - this.accumulatedCost.models[model] = { - input: 0, - output: 0, - total: 0, - tokens: { input: 0, output: 0, total: 0 }, - } - } - this.accumulatedCost.models[model].input += cost.input || 0 - this.accumulatedCost.models[model].output += cost.output || 0 - this.accumulatedCost.models[model].total += cost.total || 0 - if (tokens) { - this.accumulatedCost.models[model].tokens.input += tokens.input || 0 - this.accumulatedCost.models[model].tokens.output += tokens.output || 0 - this.accumulatedCost.models[model].tokens.total += tokens.total || 0 - } - } - } - + // Cost is recorded into the usage_log ledger and reconciled at completion + // boundaries (see recordExecutionUsage); onBlockComplete only persists the + // last-completed-block progress marker. await this.trackProgressWrite( this.persistLastCompletedBlock({ blockId, @@ -341,76 +281,6 @@ export class LoggingSession { success: !output?.output?.error, }) ) - - if ( - blockOutput?.cost && - typeof blockOutput.cost.total === 'number' && - blockOutput.cost.total > 0 - ) { - void this.trackProgressWrite(this.flushAccumulatedCost()) - } - } - - private async flushAccumulatedCost(): Promise { - try { - await db - .update(workflowExecutionLogs) - .set({ - cost: { - total: this.accumulatedCost.total, - input: this.accumulatedCost.input, - output: this.accumulatedCost.output, - tokens: this.accumulatedCost.tokens, - models: this.accumulatedCost.models, - }, - }) - .where( - and( - eq(workflowExecutionLogs.workflowId, this.workflowId), - eq(workflowExecutionLogs.executionId, this.executionId) - ) - ) - - this.costFlushed = true - } catch (error) { - logger.error(`Failed to flush accumulated cost for execution ${this.executionId}:`, { - error: toError(error).message, - }) - } - } - - private async loadExistingCost(): Promise { - try { - const [existing] = await db - .select({ cost: workflowExecutionLogs.cost }) - .from(workflowExecutionLogs) - .where( - and( - eq(workflowExecutionLogs.workflowId, this.workflowId), - eq(workflowExecutionLogs.executionId, this.executionId) - ) - ) - .limit(1) - - if (existing?.cost) { - const cost = existing.cost as AccumulatedCost - this.accumulatedCost = { - total: cost.total || BASE_EXECUTION_CHARGE, - input: cost.input || 0, - output: cost.output || 0, - tokens: { - input: cost.tokens?.input || 0, - output: cost.tokens?.output || 0, - total: cost.tokens?.total || 0, - }, - models: cost.models || {}, - } - } - } catch (error) { - logger.error(`Failed to load existing cost for execution ${this.executionId}:`, { - error: toError(error).message, - }) - } } async start(params: SessionStartParams): Promise { @@ -451,8 +321,9 @@ export class LoggingSession { deploymentVersionId, }) } else { + // Resume: no cost reload needed. Billing reconciles from the usage_log + // ledger (pre-pause rows already exist) plus the live cost summary. this.isResume = true - await this.loadExistingCost() } } catch (error) { if (this.requestId) { @@ -577,6 +448,8 @@ export class LoggingSession { const hasProvidedSpans = Array.isArray(traceSpans) && traceSpans.length > 0 + // calculateCostSummary([]) / (undefined) already returns the base-charge + // summary, so the no-spans branch needs no separate literal. const costSummary = skipCost ? { totalCost: 0, @@ -586,22 +459,10 @@ export class LoggingSession { totalPromptTokens: 0, totalCompletionTokens: 0, baseExecutionCharge: 0, - modelCost: 0, models: {}, + charges: {}, } - : hasProvidedSpans - ? calculateCostSummary(traceSpans) - : { - totalCost: BASE_EXECUTION_CHARGE, - totalInputCost: 0, - totalOutputCost: 0, - totalTokens: 0, - totalPromptTokens: 0, - totalCompletionTokens: 0, - baseExecutionCharge: BASE_EXECUTION_CHARGE, - modelCost: 0, - models: {}, - } + : calculateCostSummary(traceSpans) const message = error?.message || 'Run failed before starting blocks' @@ -710,19 +571,9 @@ export class LoggingSession { return } - const costSummary = traceSpans?.length - ? calculateCostSummary(traceSpans) - : { - totalCost: BASE_EXECUTION_CHARGE, - totalInputCost: 0, - totalOutputCost: 0, - totalTokens: 0, - totalPromptTokens: 0, - totalCompletionTokens: 0, - baseExecutionCharge: BASE_EXECUTION_CHARGE, - modelCost: 0, - models: {}, - } + // calculateCostSummary handles empty/undefined spans by returning the + // base-charge summary, so no separate no-spans literal is needed. + const costSummary = calculateCostSummary(traceSpans) await this.completeExecutionWithFinalization({ endedAt: endTime.toISOString(), @@ -814,19 +665,9 @@ export class LoggingSession { return } - const costSummary = traceSpans?.length - ? calculateCostSummary(traceSpans) - : { - totalCost: BASE_EXECUTION_CHARGE, - totalInputCost: 0, - totalOutputCost: 0, - totalTokens: 0, - totalPromptTokens: 0, - totalCompletionTokens: 0, - baseExecutionCharge: BASE_EXECUTION_CHARGE, - modelCost: 0, - models: {}, - } + // calculateCostSummary handles empty/undefined spans by returning the + // base-charge summary, so no separate no-spans literal is needed. + const costSummary = calculateCostSummary(traceSpans) await this.completeExecutionWithFinalization({ endedAt: endTime.toISOString(), @@ -1185,37 +1026,12 @@ export class LoggingSession { ) try { - const hasAccumulatedCost = - this.costFlushed || - this.accumulatedCost.total > BASE_EXECUTION_CHARGE || - this.accumulatedCost.tokens.total > 0 || - Object.keys(this.accumulatedCost.models).length > 0 - - const costSummary = hasAccumulatedCost - ? { - totalCost: this.accumulatedCost.total, - totalInputCost: this.accumulatedCost.input, - totalOutputCost: this.accumulatedCost.output, - totalTokens: this.accumulatedCost.tokens.total, - totalPromptTokens: this.accumulatedCost.tokens.input, - totalCompletionTokens: this.accumulatedCost.tokens.output, - baseExecutionCharge: BASE_EXECUTION_CHARGE, - modelCost: Math.max(0, this.accumulatedCost.total - BASE_EXECUTION_CHARGE), - models: this.accumulatedCost.models, - } - : params.traceSpans?.length - ? calculateCostSummary(params.traceSpans) - : { - totalCost: BASE_EXECUTION_CHARGE, - totalInputCost: 0, - totalOutputCost: 0, - totalTokens: 0, - totalPromptTokens: 0, - totalCompletionTokens: 0, - baseExecutionCharge: BASE_EXECUTION_CHARGE, - modelCost: 0, - models: {}, - } + // Billing is reconciled from the usage_log ledger in recordExecutionUsage; + // here we only need a cost summary to compute the run total. Derive it + // from the in-memory trace spans when available (this fallback fires when + // persisting spans failed, not when computing them did), else just the + // base execution charge. + const costSummary = calculateCostSummary(params.traceSpans) const finalOutput = params.finalOutput || { _fallback: true, error: params.errorMessage } diff --git a/apps/sim/lib/logs/execution/trace-store.ts b/apps/sim/lib/logs/execution/trace-store.ts new file mode 100644 index 0000000000..83312eb1ec --- /dev/null +++ b/apps/sim/lib/logs/execution/trace-store.ts @@ -0,0 +1,179 @@ +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import { isLargeValueRef } from '@/lib/execution/payloads/large-value-ref' +import { materializeLargeValueRef, storeLargeValue } from '@/lib/execution/payloads/store' + +const logger = createLogger('TraceStore') + +/** + * Key under which the externalized-execution-data pointer (a `__simLargeValueRef`) + * is stored on the slim `execution_data` row. + */ +export const TRACE_STORE_REF_KEY = 'traceStoreRef' + +/** + * The only metadata kept inline on the slim row (everything else lives in the + * externalized object). These two describe trace presence/count and uniquely + * survive object expiry — so a reader can still report "trace data expired (N + * spans)" after retention without an object fetch. All other fields + * (environment, trigger, tokens, models, truncation flags, and of course the + * heavy payloads) are in the stored object and recovered on materialize, so + * keeping them inline too would just be duplication. + */ +const INLINE_MARKER_KEYS = ['hasTraceSpans', 'traceSpanCount'] as const + +/** + * Read-path context. Resolves an externalized payload by storage key, authorized + * via the (already-authorized) workspace — no owner needed. + */ +interface TraceStoreReadContext { + workspaceId: string | null + workflowId: string | null + executionId: string +} + +/** + * Write-path context. Requires the execution owner's `userId`: the externalized + * object is tracked in `workspace_files`, whose `user_id` column is NOT NULL + * (FK -> user.id). Requiring it here makes "a write needs an owner" a + * compile-time invariant, so callers must resolve the owner before persisting. + */ +interface TraceStoreWriteContext extends TraceStoreReadContext { + userId: string +} + +/** + * Recovers the workflowId embedded in a large-value storage key + * (`execution/{workspaceId}/{workflowId}/{executionId}/`). Used when the + * log row's workflowId has been nulled by workflow deletion. + */ +function workflowIdFromStorageKey(key: string | undefined): string | undefined { + if (!key) return undefined + const parts = key.split('/') + return parts.length >= 5 && parts[0] === 'execution' ? parts[2] : undefined +} + +/** + * Recursively removes `cost` from trace spans before persistence. Cost lives in + * exactly one place — the usage_log ledger — so persisted spans carry only + * structure, timing, and tokens (KTD7). Must run AFTER `calculateCostSummary` + * has consumed span costs in memory. + */ +export function stripSpanCosts(spans: unknown): void { + if (!Array.isArray(spans)) return + for (const span of spans) { + if (!span || typeof span !== 'object') continue + const record = span as { cost?: unknown; children?: unknown } + if ('cost' in record) record.cost = undefined + if (Array.isArray(record.children)) stripSpanCosts(record.children) + } +} + +/** + * Externalizes heavy `execution_data` to object storage as a single large value + * (reusing the execution-context large-value store + its reference/dependency/GC + * machinery — KTD4/KTD8), returning a slim row payload that keeps inline markers + * plus the `__simLargeValueRef` pointer. + * + * On any failure (no scope, oversized, storage error) the original (already + * cost-stripped) execution data is returned unchanged so the log is never lost. + */ +export async function externalizeExecutionData( + executionData: Record, + context: TraceStoreWriteContext +): Promise> { + const { workspaceId, workflowId, executionId, userId } = context + // workspaceId/workflowId build the storage key and can be null for + // deleted-workflow rows. userId is type-guaranteed by TraceStoreWriteContext; + // the falsy check is a defensive guard against an empty string. If any are + // missing the durable write can't succeed, so keep the data inline. + if (!workspaceId || !workflowId || !userId) return executionData + + try { + const json = JSON.stringify(executionData) + const size = Buffer.byteLength(json, 'utf8') + + // storeLargeValue persists to the execution bucket with a conforming key and + // registers owner + dependency closure (trace -> nested span large values), + // so GC keeps nested children alive while this run's log row exists. + const ref = await storeLargeValue(executionData, json, size, { + workspaceId, + workflowId, + executionId, + userId, + requireDurable: true, + }) + + const { preview: _preview, ...slimRef } = ref + + const slim: Record = { [TRACE_STORE_REF_KEY]: slimRef } + for (const key of INLINE_MARKER_KEYS) { + if (key in executionData) slim[key] = executionData[key] + } + return slim + } catch (error) { + logger.warn('Failed to externalize execution data; keeping inline', { + executionId, + error: toError(error).message, + }) + return executionData + } +} + +/** + * Resolves an `execution_data` row into its full form for reads. When the row + * carries a trace-store pointer, the payload is materialized from storage and + * merged with the inline markers; otherwise the row is returned unchanged + * (inline / pre-externalization runs). One level only — nested span + * `__simLargeValueRef` stubs remain as previews, matching prior behavior. + * + * Returns metadata-only (the slim row minus the pointer) if the object is + * missing/unreadable (e.g. post-retention) so reads degrade rather than crash. + */ +export async function materializeExecutionData( + executionData: Record | null | undefined, + context: TraceStoreReadContext +): Promise> { + if (!executionData) return {} + + const ref = executionData[TRACE_STORE_REF_KEY] + if (!isLargeValueRef(ref)) return executionData + + const { [TRACE_STORE_REF_KEY]: _pointer, ...markers } = executionData + + if (!context.workspaceId) return markers + + // workflowId is `set null` on workflow delete, but the ref key embeds the + // original workflowId — recover it so deleted-workflow logs stay readable. + // Workspace authorization still comes from the (authorized) caller context. + const workflowId = context.workflowId ?? workflowIdFromStorageKey(ref.key) + if (!workflowId) return markers + + try { + const materialized = await materializeLargeValueRef(ref, { + workspaceId: context.workspaceId, + workflowId, + executionId: context.executionId, + maxBytes: ref.size, + // Read-only: the value is already referenced by its own execution; don't + // re-register (or fail) on every view/export. + trackReference: false, + }) + + if (!materialized || typeof materialized !== 'object') { + logger.warn('Trace store object unavailable; returning metadata only', { + executionId: context.executionId, + key: ref.key, + }) + return markers + } + + return { ...(materialized as Record), ...markers } + } catch (error) { + logger.warn('Failed to materialize execution data; returning metadata only', { + executionId: context.executionId, + error: toError(error).message, + }) + return markers + } +} diff --git a/apps/sim/lib/logs/fetch-log-detail.ts b/apps/sim/lib/logs/fetch-log-detail.ts index 1a5aea4dc2..e1cef8728d 100644 --- a/apps/sim/lib/logs/fetch-log-detail.ts +++ b/apps/sim/lib/logs/fetch-log-detail.ts @@ -3,14 +3,69 @@ import { jobExecutionLogs, pausedExecutions, permissions, + usageLog, workflow, workflowDeploymentVersion, workflowExecutionLogs, } from '@sim/db/schema' import { and, eq, type SQL } from 'drizzle-orm' +import type { CostLedger } from '@/lib/api/contracts/logs' +import { materializeExecutionData } from '@/lib/logs/execution/trace-store' type LookupColumn = 'id' | 'executionId' +async function buildCostLedger(executionId: string): Promise { + const rows = await db + .select({ + category: usageLog.category, + description: usageLog.description, + cost: usageLog.cost, + metadata: usageLog.metadata, + }) + .from(usageLog) + .where(and(eq(usageLog.executionId, executionId), eq(usageLog.source, 'workflow'))) + + if (rows.length === 0) return null + + type LedgerItem = CostLedger['items'][number] + const byKey = new Map() + for (const row of rows) { + const metadata = (row.metadata ?? {}) as { inputTokens?: number; outputTokens?: number } + const category = row.category as LedgerItem['category'] + const key = `${category}::${row.description}` + const existing = byKey.get(key) + if (existing) { + existing.cost += Number(row.cost) + if (typeof metadata.inputTokens === 'number') { + existing.inputTokens = Math.max(existing.inputTokens ?? 0, metadata.inputTokens) + } + if (typeof metadata.outputTokens === 'number') { + existing.outputTokens = Math.max(existing.outputTokens ?? 0, metadata.outputTokens) + } + } else { + byKey.set(key, { + category, + description: row.description, + cost: Number(row.cost), + ...(typeof metadata.inputTokens === 'number' ? { inputTokens: metadata.inputTokens } : {}), + ...(typeof metadata.outputTokens === 'number' + ? { outputTokens: metadata.outputTokens } + : {}), + }) + } + } + + const items = [...byKey.values()] + const total = items.reduce((sum, item) => sum + item.cost, 0) + return { total, items } +} + +export function jobCostTotal(raw: unknown): { total: number } | null { + const total = (raw as { total?: unknown } | null | undefined)?.total + const n = total == null ? Number.NaN : Number(total) + return Number.isFinite(n) ? { total: n } : null +} + interface FetchLogDetailArgs { userId: string workspaceId: string @@ -47,7 +102,7 @@ export async function fetchLogDetail({ endedAt: workflowExecutionLogs.endedAt, totalDurationMs: workflowExecutionLogs.totalDurationMs, executionData: workflowExecutionLogs.executionData, - cost: workflowExecutionLogs.cost, + costTotal: workflowExecutionLogs.costTotal, files: workflowExecutionLogs.files, createdAt: workflowExecutionLogs.createdAt, workflowName: workflow.name, @@ -105,6 +160,18 @@ export async function fetchLogDetail({ (totalPauseCount > 0 && resumedCount < totalPauseCount) || (log.pausedStatus !== null && log.pausedStatus !== 'fully_resumed') + // Cost is sourced exclusively from the usage_log ledger (itemized breakdown) + // and its cost_total projection (run total). The cost jsonb is never read. + const costLedger = await buildCostLedger(log.executionId) + const totalDollars = costLedger?.total ?? (log.costTotal != null ? Number(log.costTotal) : null) + + // Trace spans / heavy execution data may live in object storage; resolve the + // pointer here (no-op for inline / pre-externalization rows). + const executionData = await materializeExecutionData( + log.executionData as Record | null, + { workspaceId, workflowId: log.workflowId, executionId: log.executionId } + ) + return { id: log.id, workflowId: log.workflowId, @@ -119,7 +186,8 @@ export async function fetchLogDetail({ createdAt: log.startedAt.toISOString(), workflow: workflowSummary, jobTitle: null, - cost: log.cost ?? null, + cost: totalDollars != null ? { total: totalDollars } : null, + costLedger, pauseSummary: { status: log.pausedStatus ?? null, total: totalPauseCount, @@ -128,7 +196,7 @@ export async function fetchLogDetail({ hasPendingPause, executionData: { totalDuration: log.totalDurationMs, - ...((log.executionData as Record | null) ?? {}), + ...executionData, enhanced: true as const, }, files: log.files ?? null, @@ -184,7 +252,7 @@ export async function fetchLogDetail({ createdAt: jobLog.startedAt.toISOString(), workflow: null, jobTitle: ((execData.trigger as Record | undefined)?.source as string) ?? null, - cost: jobLog.cost ?? null, + cost: jobCostTotal(jobLog.cost), pauseSummary: { status: null, total: 0, resumed: 0 }, hasPendingPause: false, executionData: { diff --git a/apps/sim/lib/logs/filters.ts b/apps/sim/lib/logs/filters.ts index cb2e1dccf2..0569e535a5 100644 --- a/apps/sim/lib/logs/filters.ts +++ b/apps/sim/lib/logs/filters.ts @@ -195,7 +195,8 @@ function buildSearchConditions(params: { } function buildCostCondition(operator: ComparisonOperator, value: number): SQL { - const costField = sql`(${workflowExecutionLogs.cost}->>'total')::numeric` + // Indexed projection of the usage_log ledger (dollars); no live aggregation. + const costField = sql`${workflowExecutionLogs.costTotal}` switch (operator) { case '=': diff --git a/apps/sim/lib/table/service.ts b/apps/sim/lib/table/service.ts index 18f1fda418..ae4bce8eb3 100644 --- a/apps/sim/lib/table/service.ts +++ b/apps/sim/lib/table/service.ts @@ -18,8 +18,10 @@ import { createLogger } from '@sim/logger' import { getPostgresErrorCode } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { and, count, eq, gt, gte, inArray, isNull, type SQL, sql } from 'drizzle-orm' +import { MATERIALIZE_CONCURRENCY, mapWithConcurrency } from '@/lib/core/utils/concurrency' import { generateRestoreName } from '@/lib/core/utils/restore-name' import type { DbOrTx } from '@/lib/db/types' +import { materializeExecutionData } from '@/lib/logs/execution/trace-store' import { COLUMN_TYPES, NAME_PATTERN, TABLE_LIMITS, USER_TABLE_ROWS_SQL_NAME } from './constants' import { buildFilterClause, buildSortClause } from './sql' import { fireTableTrigger } from './trigger' @@ -3935,18 +3937,26 @@ async function backfillGroupOutputsFromLogs(opts: { const logs = await db .select({ executionId: workflowExecutionLogs.executionId, + workflowId: workflowExecutionLogs.workflowId, + workspaceId: workflowExecutionLogs.workspaceId, executionData: workflowExecutionLogs.executionData, }) .from(workflowExecutionLogs) .where(inArray(workflowExecutionLogs.executionId, executionIds)) const logByExecutionId = new Map() - for (const log of logs) { + // Heavy execution data may live in object storage; resolve pointers (bounded + // concurrency) so trace spans are available for table-column enrichment. + await mapWithConcurrency(logs, MATERIALIZE_CONCURRENCY, async (log) => { + const executionData = await materializeExecutionData( + log.executionData as Record | null, + { workspaceId: log.workspaceId, workflowId: log.workflowId, executionId: log.executionId } + ) logByExecutionId.set( log.executionId, - (log.executionData as { traceSpans?: BackfillTraceSpan[] }) ?? {} + (executionData as { traceSpans?: BackfillTraceSpan[] }) ?? {} ) - } + }) const updates: Array<{ rowId: string; data: RowData }> = [] for (const r of rowRecords) { diff --git a/apps/sim/lib/workspaces/organization/types.ts b/apps/sim/lib/workspaces/organization/types.ts index e3f07c50c6..fd1e770eb4 100644 --- a/apps/sim/lib/workspaces/organization/types.ts +++ b/apps/sim/lib/workspaces/organization/types.ts @@ -71,7 +71,6 @@ interface MemberUsageData { isOverLimit: boolean role: string joinedAt: string - lastActive: string | null } interface OrganizationBillingData { diff --git a/apps/sim/providers/utils.ts b/apps/sim/providers/utils.ts index 205fb30787..5815f0aecf 100644 --- a/apps/sim/providers/utils.ts +++ b/apps/sim/providers/utils.ts @@ -3,7 +3,7 @@ import { getErrorMessage } from '@sim/utils/errors' import type OpenAI from 'openai' import type { ChatCompletionChunk } from 'openai/resources/chat/completions' import type { CompletionUsage } from 'openai/resources/completions' -import { dollarsToCredits } from '@/lib/billing/credits/conversion' +import { formatCreditCost } from '@/lib/billing/credits/conversion' import { env } from '@/lib/core/config/env' import { getBlacklistedProvidersFromEnv, isHosted } from '@/lib/core/config/feature-flags' import { @@ -700,11 +700,7 @@ export function getModelPricing(modelId: string): any { * @returns Formatted credit string (e.g. "200 credits", "<1 credit", "0 credits") */ export function formatCost(cost: number): string { - if (cost === undefined || cost === null) return '—' - const credits = dollarsToCredits(cost) - if (credits <= 0 && cost > 0) return '<1 credit' - if (credits <= 0) return '0 credits' - return `${credits.toLocaleString()} credits` + return formatCreditCost(cost) ?? '—' } /** diff --git a/apps/sim/scripts/backfill-trace-spans.ts b/apps/sim/scripts/backfill-trace-spans.ts new file mode 100644 index 0000000000..1fb3a9fd8f --- /dev/null +++ b/apps/sim/scripts/backfill-trace-spans.ts @@ -0,0 +1,188 @@ +#!/usr/bin/env bun + +/** + * One-shot, idempotent, resumable backfill that externalizes inline heavy + * `execution_data` (traceSpans, finalOutput, workflowInput, ...) into the + * execution-context large-value store, matching the completion path (cost-stripped + * spans, trace pointer + markers, owner/dependency + execution_log reference + * registration). Skips running rows and rows already carrying the pointer. + * + * Requires object storage to be configured; self-hosted deployments without it + * keep `execution_data` inline (reads resolve inline transparently) and can skip + * this script entirely. + * + * NOTE: the companion `cost_total` / `models_used` backfill is done in SQL by + * migration 0220 (batched, idempotent), so it runs for everyone — including + * self-hosted — and is intentionally NOT part of this script. + * + * Usage: + * DATABASE_URL=... bun apps/sim/scripts/backfill-trace-spans.ts [--max-batches=] + */ + +import { db } from '@sim/db' +import { workflowExecutionLogs } from '@sim/db/schema' +import { toError } from '@sim/utils/errors' +import { and, asc, eq, gt, sql } from 'drizzle-orm' +import { + collectLargeValueReferenceKeys, + replaceLargeValueReferenceKeysWithClient, +} from '@/lib/execution/payloads/large-value-metadata' +import { + externalizeExecutionData, + stripSpanCosts, + TRACE_STORE_REF_KEY, +} from '@/lib/logs/execution/trace-store' + +const TRACE_BATCH_SIZE = 100 + +/** + * Recursively counts trace spans (matching the completion path). Legacy rows + * predate the inline hasTraceSpans/traceSpanCount markers, so we derive them + * before externalizing — otherwise a post-expiry degraded read can't report + * "trace data expired (N spans)". + */ +function countTraceSpans(spans: unknown): number { + if (!Array.isArray(spans)) return 0 + return spans.reduce( + (count: number, span) => + count + 1 + countTraceSpans((span as { children?: unknown } | null)?.children), + 0 + ) +} + +interface Options { + maxBatches: number +} + +function parseArgs(argv: string[]): Options { + const maxBatchesArg = argv.find((a) => a.startsWith('--max-batches=')) + const maxBatches = maxBatchesArg + ? Number.parseInt(maxBatchesArg.slice('--max-batches='.length), 10) + : Number.POSITIVE_INFINITY + + if (Number.isNaN(maxBatches) || maxBatches <= 0) { + throw new Error('--max-batches must be a positive integer') + } + + return { maxBatches } +} + +/** Externalize inline heavy execution_data into the large-value store. */ +async function backfillTraceStorage( + maxBatches: number +): Promise<{ migrated: number; failed: number }> { + let migrated = 0 + let failed = 0 + // Keyset cursor by id: every row is visited at most once per run, so rows that + // can't be externalized (storage error, oversized) aren't re-selected into an + // infinite loop. A fresh re-run (cursor reset) retries any that failed. + let lastId = '' + + for (let batch = 0; batch < maxBatches; batch++) { + const rows = await db + .select({ + id: workflowExecutionLogs.id, + workspaceId: workflowExecutionLogs.workspaceId, + workflowId: workflowExecutionLogs.workflowId, + executionId: workflowExecutionLogs.executionId, + executionData: workflowExecutionLogs.executionData, + }) + .from(workflowExecutionLogs) + .where( + and( + sql`${workflowExecutionLogs.endedAt} IS NOT NULL`, + // Skip deleted-workflow rows: externalization requires a workflowId. + sql`${workflowExecutionLogs.workflowId} IS NOT NULL`, + sql`${workflowExecutionLogs.executionData} ? 'traceSpans'`, + sql`NOT (${workflowExecutionLogs.executionData} ? ${TRACE_STORE_REF_KEY})`, + lastId ? gt(workflowExecutionLogs.id, lastId) : undefined + ) + ) + .orderBy(asc(workflowExecutionLogs.id)) + .limit(TRACE_BATCH_SIZE) + + if (rows.length === 0) break + + for (const row of rows) { + try { + const executionData = (row.executionData ?? {}) as Record + // Derive the inline markers legacy rows lack so externalizeExecutionData + // carries them onto the slim row (they survive object expiry). + const traceSpanCount = countTraceSpans(executionData.traceSpans) + executionData.hasTraceSpans = traceSpanCount > 0 + executionData.traceSpanCount = traceSpanCount + stripSpanCosts(executionData.traceSpans) + // workspace_files.user_id (NOT NULL) needs the execution owner; legacy + // rows carry it under executionData.environment.userId. Rows without an + // owner can't be externalized — count them as failed and skip. + const environment = executionData.environment as { userId?: string } | undefined + const ownerUserId = environment?.userId + if (!ownerUserId) { + failed++ + continue + } + const slim = await externalizeExecutionData(executionData, { + workspaceId: row.workspaceId, + workflowId: row.workflowId, + executionId: row.executionId, + userId: ownerUserId, + }) + + if (!(TRACE_STORE_REF_KEY in slim)) { + failed++ + continue + } + + await db.transaction(async (tx) => { + await tx + .update(workflowExecutionLogs) + .set({ executionData: slim }) + .where(eq(workflowExecutionLogs.id, row.id)) + + await replaceLargeValueReferenceKeysWithClient( + tx, + { + workspaceId: row.workspaceId, + workflowId: row.workflowId, + executionId: row.executionId, + source: 'execution_log', + }, + collectLargeValueReferenceKeys(slim) + ) + }) + + migrated++ + } catch (error) { + failed++ + console.error(` [trace] row ${row.id} failed: ${toError(error).message}`) + } + } + + // Advance the cursor past this batch so failed rows aren't re-selected. + lastId = rows[rows.length - 1].id + + console.log(` [trace] batch ${batch + 1}: migrated ${migrated}, failed ${failed}`) + } + + return { migrated, failed } +} + +async function main(): Promise { + const options = parseArgs(process.argv.slice(2)) + const startedAt = Date.now() + + console.log('Backfilling trace storage (externalizing execution_data)…') + const { migrated, failed } = await backfillTraceStorage(options.maxBatches) + console.log(`Trace storage done: ${migrated} migrated, ${failed} skipped/failed.`) + + console.log(`Backfill complete in ${((Date.now() - startedAt) / 1000).toFixed(1)}s.`) +} + +main() + .catch((err) => { + console.error('Backfill failed:', err) + process.exit(1) + }) + .finally(() => { + process.exit(0) + }) diff --git a/bun.lock b/bun.lock index fdee6edfc4..070992c9a4 100644 --- a/bun.lock +++ b/bun.lock @@ -1,6 +1,5 @@ { "lockfileVersion": 1, - "configVersion": 0, "workspaces": { "": { "name": "simstudio", diff --git a/packages/db/migrations/0220_early_hellion.sql b/packages/db/migrations/0220_early_hellion.sql new file mode 100644 index 0000000000..558f964c2d --- /dev/null +++ b/packages/db/migrations/0220_early_hellion.sql @@ -0,0 +1,36 @@ +ALTER TABLE "workflow_execution_logs" ADD COLUMN IF NOT EXISTS "cost_total" numeric;--> statement-breakpoint +ALTER TABLE "workflow_execution_logs" ADD COLUMN IF NOT EXISTS "models_used" text[];--> statement-breakpoint +COMMIT;--> statement-breakpoint +ALTER TYPE "public"."usage_log_category" ADD VALUE IF NOT EXISTS 'tool';--> statement-breakpoint +CREATE OR REPLACE PROCEDURE backfill_wel_cost_total_0220() LANGUAGE plpgsql AS $$ +DECLARE + updated integer; +BEGIN + LOOP + WITH candidates AS ( + SELECT id FROM workflow_execution_logs + WHERE cost_total IS NULL + AND cost ? 'total' + AND (cost->>'total') ~ '^-?[0-9]+(\.[0-9]+)?$' + LIMIT 5000 + ) + UPDATE workflow_execution_logs wel + SET cost_total = NULLIF(wel.cost->>'total', '')::numeric, + models_used = CASE + WHEN jsonb_typeof(wel.cost->'models') = 'object' + THEN ARRAY(SELECT jsonb_object_keys(wel.cost->'models')) + ELSE wel.models_used + END + FROM candidates + WHERE wel.id = candidates.id; + GET DIAGNOSTICS updated = ROW_COUNT; + EXIT WHEN updated = 0; + COMMIT; + END LOOP; +END; +$$;--> statement-breakpoint +CALL backfill_wel_cost_total_0220();--> statement-breakpoint +DROP PROCEDURE backfill_wel_cost_total_0220();--> statement-breakpoint +CREATE INDEX CONCURRENTLY IF NOT EXISTS "usage_log_execution_id_idx" ON "usage_log" USING btree ("execution_id");--> statement-breakpoint +CREATE INDEX CONCURRENTLY IF NOT EXISTS "workflow_execution_logs_workspace_cost_total_idx" ON "workflow_execution_logs" USING btree ("workspace_id","cost_total");--> statement-breakpoint +CREATE INDEX CONCURRENTLY IF NOT EXISTS "workflow_execution_logs_models_used_idx" ON "workflow_execution_logs" USING gin ("models_used"); diff --git a/packages/db/migrations/meta/0220_snapshot.json b/packages/db/migrations/meta/0220_snapshot.json new file mode 100644 index 0000000000..64c6073d85 --- /dev/null +++ b/packages/db/migrations/meta/0220_snapshot.json @@ -0,0 +1,17582 @@ +{ + "id": "10027d61-99fa-4082-99a8-27be35fc3d8a", + "prevId": "9c04ddff-9332-4201-be6f-98fbf006874a", + "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": {} + }, + "async_jobs_schedule_pending_run_at_idx": { + "name": "async_jobs_schedule_pending_run_at_idx", + "columns": [ + { + "expression": "run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"async_jobs\".\"type\" = 'schedule-execution' AND \"async_jobs\".\"status\" = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_schedule_processing_started_at_idx": { + "name": "async_jobs_schedule_processing_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"async_jobs\".\"type\" = 'schedule-execution' AND \"async_jobs\".\"status\" = 'processing'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "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_messages": { + "name": "copilot_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "stream_id": { + "name": "stream_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parent_message_id": { + "name": "parent_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tokens_in": { + "name": "tokens_in", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "tokens_out": { + "name": "tokens_out", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_messages_chat_message_unique": { + "name": "copilot_messages_chat_message_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_chat_created_at_idx": { + "name": "copilot_messages_chat_created_at_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_chat_seq_idx": { + "name": "copilot_messages_chat_seq_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "seq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_chat_stream_idx": { + "name": "copilot_messages_chat_stream_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "stream_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"stream_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_messages_chat_id_copilot_chats_id_fk": { + "name": "copilot_messages_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_messages", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_run_checkpoints": { + "name": "copilot_run_checkpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "pending_tool_call_id": { + "name": "pending_tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conversation_snapshot": { + "name": "conversation_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "agent_state": { + "name": "agent_state", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "provider_request": { + "name": "provider_request", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_run_checkpoints_run_id_idx": { + "name": "copilot_run_checkpoints_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_run_checkpoints_pending_tool_call_id_idx": { + "name": "copilot_run_checkpoints_pending_tool_call_id_idx", + "columns": [ + { + "expression": "pending_tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_run_checkpoints_run_pending_tool_unique": { + "name": "copilot_run_checkpoints_run_pending_tool_unique", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pending_tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_run_checkpoints_run_id_copilot_runs_id_fk": { + "name": "copilot_run_checkpoints_run_id_copilot_runs_id_fk", + "tableFrom": "copilot_run_checkpoints", + "tableTo": "copilot_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_runs": { + "name": "copilot_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_run_id": { + "name": "parent_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stream_id": { + "name": "stream_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent": { + "name": "agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "copilot_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "request_context": { + "name": "request_context", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "copilot_runs_execution_id_idx": { + "name": "copilot_runs_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_parent_run_id_idx": { + "name": "copilot_runs_parent_run_id_idx", + "columns": [ + { + "expression": "parent_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_chat_id_idx": { + "name": "copilot_runs_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_user_id_idx": { + "name": "copilot_runs_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_workflow_id_idx": { + "name": "copilot_runs_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_workspace_id_idx": { + "name": "copilot_runs_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_status_idx": { + "name": "copilot_runs_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_chat_execution_idx": { + "name": "copilot_runs_chat_execution_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_execution_started_at_idx": { + "name": "copilot_runs_execution_started_at_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_workspace_completed_at_id_idx": { + "name": "copilot_runs_workspace_completed_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"completed_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_stream_id_unique": { + "name": "copilot_runs_stream_id_unique", + "columns": [ + { + "expression": "stream_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_runs_chat_id_copilot_chats_id_fk": { + "name": "copilot_runs_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_user_id_user_id_fk": { + "name": "copilot_runs_user_id_user_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_workflow_id_workflow_id_fk": { + "name": "copilot_runs_workflow_id_workflow_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_workspace_id_workspace_id_fk": { + "name": "copilot_runs_workspace_id_workspace_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_workflow_read_hashes": { + "name": "copilot_workflow_read_hashes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_workflow_read_hashes_chat_id_idx": { + "name": "copilot_workflow_read_hashes_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_workflow_read_hashes_workflow_id_idx": { + "name": "copilot_workflow_read_hashes_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_workflow_read_hashes_chat_workflow_unique": { + "name": "copilot_workflow_read_hashes_chat_workflow_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_workflow_read_hashes_chat_id_copilot_chats_id_fk": { + "name": "copilot_workflow_read_hashes_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_workflow_read_hashes", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_workflow_read_hashes_workflow_id_workflow_id_fk": { + "name": "copilot_workflow_read_hashes_workflow_id_workflow_id_fk", + "tableFrom": "copilot_workflow_read_hashes", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credential": { + "name": "credential", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "credential_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "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.execution_large_value_dependencies": { + "name": "execution_large_value_dependencies", + "schema": "", + "columns": { + "parent_key": { + "name": "parent_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_key": { + "name": "child_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "execution_large_value_dependencies_workspace_parent_key_idx": { + "name": "execution_large_value_dependencies_workspace_parent_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_value_dependencies_workspace_child_key_idx": { + "name": "execution_large_value_dependencies_workspace_child_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "child_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "execution_large_value_dependencies_workspace_id_workspace_id_fk": { + "name": "execution_large_value_dependencies_workspace_id_workspace_id_fk", + "tableFrom": "execution_large_value_dependencies", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "execution_large_value_dependencies_parent_key_child_key_pk": { + "name": "execution_large_value_dependencies_parent_key_child_key_pk", + "columns": ["parent_key", "child_key"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.execution_large_value_references": { + "name": "execution_large_value_references", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "execution_large_value_reference_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "execution_large_value_references_workspace_execution_source_idx": { + "name": "execution_large_value_references_workspace_execution_source_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "execution_large_value_references_workspace_id_workspace_id_fk": { + "name": "execution_large_value_references_workspace_id_workspace_id_fk", + "tableFrom": "execution_large_value_references", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "execution_large_value_references_workflow_id_workflow_id_fk": { + "name": "execution_large_value_references_workflow_id_workflow_id_fk", + "tableFrom": "execution_large_value_references", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "execution_large_value_references_key_execution_id_source_pk": { + "name": "execution_large_value_references_key_execution_id_source_pk", + "columns": ["key", "execution_id", "source"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.execution_large_values": { + "name": "execution_large_values", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_execution_id": { + "name": "owner_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "execution_large_values_owner_execution_id_idx": { + "name": "execution_large_values_owner_execution_id_idx", + "columns": [ + { + "expression": "owner_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_values_cleanup_idx": { + "name": "execution_large_values_cleanup_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"execution_large_values\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_values_tombstone_cleanup_idx": { + "name": "execution_large_values_tombstone_cleanup_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"execution_large_values\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "execution_large_values_workspace_id_workspace_id_fk": { + "name": "execution_large_values_workspace_id_workspace_id_fk", + "tableFrom": "execution_large_values", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "execution_large_values_workflow_id_workflow_id_fk": { + "name": "execution_large_values_workflow_id_workflow_id_fk", + "tableFrom": "execution_large_values", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.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 + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "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 + }, + "cancel_at": { + "name": "cancel_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "canceled_at": { + "name": "canceled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "seats": { + "name": "seats", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "trial_start": { + "name": "trial_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trial_end": { + "name": "trial_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "billing_interval": { + "name": "billing_interval", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_schedule_id": { + "name": "stripe_schedule_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false + } + }, + "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 + }, + "limit": { + "name": "limit", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "processed_count": { + "name": "processed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_manual_run": { + "name": "is_manual_run", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "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 + }, + "event_key": { + "name": "event_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "billing_entity_type": { + "name": "billing_entity_type", + "type": "billing_entity_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "billing_entity_id": { + "name": "billing_entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "billing_period_start": { + "name": "billing_period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "billing_period_end": { + "name": "billing_period_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "usage_log_user_created_at_idx": { + "name": "usage_log_user_created_at_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_source_idx": { + "name": "usage_log_source_idx", + "columns": [ + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_workspace_id_idx": { + "name": "usage_log_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_workflow_id_idx": { + "name": "usage_log_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_event_key_unique": { + "name": "usage_log_event_key_unique", + "columns": [ + { + "expression": "event_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"usage_log\".\"event_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_billing_entity_period_idx": { + "name": "usage_log_billing_entity_period_idx", + "columns": [ + { + "expression": "billing_entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_period_start", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_period_end", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_log\".\"billing_entity_type\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_workspace_created_at_idx": { + "name": "usage_log_workspace_created_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_execution_id_idx": { + "name": "usage_log_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "usage_log_user_id_user_id_fk": { + "name": "usage_log_user_id_user_id_fk", + "tableFrom": "usage_log", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "usage_log_workspace_id_workspace_id_fk": { + "name": "usage_log_workspace_id_workspace_id_fk", + "tableFrom": "usage_log", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "usage_log_workflow_id_workflow_id_fk": { + "name": "usage_log_workflow_id_workflow_id_fk", + "tableFrom": "usage_log", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "usage_log_billing_scope_all_or_none": { + "name": "usage_log_billing_scope_all_or_none", + "value": "(\n (\"usage_log\".\"billing_entity_type\" IS NULL AND \"usage_log\".\"billing_entity_id\" IS NULL AND \"usage_log\".\"billing_period_start\" IS NULL AND \"usage_log\".\"billing_period_end\" IS NULL)\n OR\n (\"usage_log\".\"billing_entity_type\" IS NOT NULL AND \"usage_log\".\"billing_entity_id\" IS NOT NULL AND \"usage_log\".\"billing_period_start\" IS NOT NULL AND \"usage_log\".\"billing_period_end\" IS NOT NULL AND \"usage_log\".\"billing_period_start\" < \"usage_log\".\"billing_period_end\")\n )" + } + }, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "normalized_email": { + "name": "normalized_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'user'" + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + }, + "user_normalized_email_unique": { + "name": "user_normalized_email_unique", + "nullsNotDistinct": false, + "columns": ["normalized_email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_stats": { + "name": "user_stats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "total_manual_executions": { + "name": "total_manual_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_api_calls": { + "name": "total_api_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_webhook_triggers": { + "name": "total_webhook_triggers", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_scheduled_executions": { + "name": "total_scheduled_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_chat_executions": { + "name": "total_chat_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_mcp_executions": { + "name": "total_mcp_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_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 + }, + "cost_total": { + "name": "cost_total", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "models_used": { + "name": "models_used", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "files": { + "name": "files", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_execution_logs_workflow_id_idx": { + "name": "workflow_execution_logs_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_state_snapshot_id_idx": { + "name": "workflow_execution_logs_state_snapshot_id_idx", + "columns": [ + { + "expression": "state_snapshot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_deployment_version_id_idx": { + "name": "workflow_execution_logs_deployment_version_id_idx", + "columns": [ + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_trigger_idx": { + "name": "workflow_execution_logs_trigger_idx", + "columns": [ + { + "expression": "trigger", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_level_idx": { + "name": "workflow_execution_logs_level_idx", + "columns": [ + { + "expression": "level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_started_at_idx": { + "name": "workflow_execution_logs_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_execution_id_unique": { + "name": "workflow_execution_logs_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workflow_started_at_idx": { + "name": "workflow_execution_logs_workflow_started_at_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workspace_started_at_idx": { + "name": "workflow_execution_logs_workspace_started_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workspace_cost_total_idx": { + "name": "workflow_execution_logs_workspace_cost_total_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_total", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_models_used_idx": { + "name": "workflow_execution_logs_models_used_idx", + "columns": [ + { + "expression": "models_used", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "workflow_execution_logs_workspace_ended_at_id_idx": { + "name": "workflow_execution_logs_workspace_ended_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"ended_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_running_started_at_idx": { + "name": "workflow_execution_logs_running_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "status = 'running'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "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 + }, + "infra_retry_count": { + "name": "infra_retry_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "last_failed_at": { + "name": "last_failed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "source_type": { + "name": "source_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'workflow'" + }, + "job_title": { + "name": "job_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lifecycle": { + "name": "lifecycle", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'persistent'" + }, + "success_condition": { + "name": "success_condition", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "max_runs": { + "name": "max_runs", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "run_count": { + "name": "run_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "source_chat_id": { + "name": "source_chat_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_task_name": { + "name": "source_task_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_user_id": { + "name": "source_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_workspace_id": { + "name": "source_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "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": {} + }, + "workflow_schedule_due_workflow_idx": { + "name": "workflow_schedule_due_workflow_idx", + "columns": [ + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_schedule\".\"archived_at\" IS NULL AND \"workflow_schedule\".\"status\" NOT IN ('disabled', 'completed') AND (\"workflow_schedule\".\"source_type\" = 'workflow' OR \"workflow_schedule\".\"source_type\" IS NULL)", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_due_job_idx": { + "name": "workflow_schedule_due_job_idx", + "columns": [ + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_schedule\".\"archived_at\" IS NULL AND \"workflow_schedule\".\"status\" NOT IN ('disabled', 'completed') AND \"workflow_schedule\".\"source_type\" = 'job'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_schedule_workflow_id_workflow_id_fk": { + "name": "workflow_schedule_workflow_id_workflow_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_schedule_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_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.billing_entity_type": { + "name": "billing_entity_type", + "schema": "public", + "values": ["user", "organization"] + }, + "public.chat_type": { + "name": "chat_type", + "schema": "public", + "values": ["mothership", "copilot"] + }, + "public.copilot_async_tool_status": { + "name": "copilot_async_tool_status", + "schema": "public", + "values": ["pending", "running", "completed", "failed", "cancelled", "delivered"] + }, + "public.copilot_run_status": { + "name": "copilot_run_status", + "schema": "public", + "values": ["active", "paused_waiting_for_tool", "resuming", "complete", "error", "cancelled"] + }, + "public.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.execution_large_value_reference_source": { + "name": "execution_large_value_reference_source", + "schema": "public", + "values": ["execution_log", "paused_snapshot"] + }, + "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", "tool"] + }, + "public.usage_log_source": { + "name": "usage_log_source", + "schema": "public", + "values": [ + "workflow", + "wand", + "copilot", + "workspace-chat", + "mcp_copilot", + "mothership_block", + "knowledge-base", + "voice-input", + "enrichment" + ] + }, + "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 9ac105fb2e..f348c087a6 100644 --- a/packages/db/migrations/meta/_journal.json +++ b/packages/db/migrations/meta/_journal.json @@ -1534,6 +1534,13 @@ "when": 1780079220753, "tag": "0219_amused_leo", "breakpoints": true + }, + { + "idx": 220, + "version": "7", + "when": 1780081787541, + "tag": "0220_early_hellion", + "breakpoints": true } ] } diff --git a/packages/db/schema.ts b/packages/db/schema.ts index 32beb356dd..4e9b691a4c 100644 --- a/packages/db/schema.ts +++ b/packages/db/schema.ts @@ -329,8 +329,25 @@ export const workflowExecutionLogs = pgTable( endedAt: timestamp('ended_at'), totalDurationMs: integer('total_duration_ms'), + /** + * Heavy trace data (traceSpans, finalOutput, workflowInput, executionState) + * is externalized to object storage; this column then holds a slim payload: + * a `traceStoreRef` (__simLargeValueRef) pointer to the stored object plus + * inline markers (hasTraceSpans, traceSpanCount, environment, trigger, + * truncation flags). It also still holds the FULL payload inline for legacy + * / not-yet-backfilled rows, for the storage-write-failure fallback, and for + * job_execution_logs. Required — not droppable. Read it via + * `materializeExecutionData`, which resolves the pointer. + */ executionData: jsonb('execution_data').notNull().default('{}'), + /** @deprecated Not written/read; cost lives in usage_log + the `cost_total` projection. Drop in a follow-up PR after the `cost_total` backfill. */ cost: jsonb('cost'), + // Faithful, write-once projection of the run's usage_log ledger sum (dollars). + // Backs list cost display/filter/sort without live aggregation; never an + // independently-computed value (cost_total == SUM(usage_log) for the run). + costTotal: decimal('cost_total'), + // Model names used by the run (incl. zero-cost/BYOK), for the v1 model filter. + modelsUsed: text('models_used').array(), files: jsonb('files'), // File metadata for execution files createdAt: timestamp('created_at').notNull().defaultNow(), }, @@ -356,6 +373,11 @@ export const workflowExecutionLogs = pgTable( table.workspaceId, table.startedAt ), + workspaceCostTotalIdx: index('workflow_execution_logs_workspace_cost_total_idx').on( + table.workspaceId, + table.costTotal + ), + modelsUsedIdx: index('workflow_execution_logs_models_used_idx').using('gin', table.modelsUsed), workspaceEndedAtIdIdx: index('workflow_execution_logs_workspace_ended_at_id_idx').on( table.workspaceId, sql`date_trunc('milliseconds', ${table.endedAt})`, @@ -881,39 +903,33 @@ export const userStats = pgTable('user_stats', { .notNull() .references(() => user.id, { onDelete: 'cascade' }) .unique(), // One record per user - /** - * Deprecated former usage hot-path counters. - * - * These used to be incremented from execution/API/trigger/chat/MCP/A2A - * billing paths on every usage event. New usage reporting must derive - * these dimensions from `usage_log` instead of writing this row. - */ + // Retired usage hot-path counters: no writers/readers; derive from usage_log. + // Drop via DROP COLUMN in a follow-up migration. + /** @deprecated Retired usage counter; derive from usage_log. */ totalManualExecutions: integer('total_manual_executions').notNull().default(0), + /** @deprecated Retired usage counter; derive from usage_log. */ totalApiCalls: integer('total_api_calls').notNull().default(0), + /** @deprecated Retired usage counter; derive from usage_log. */ totalWebhookTriggers: integer('total_webhook_triggers').notNull().default(0), + /** @deprecated Retired usage counter; derive from usage_log. */ totalScheduledExecutions: integer('total_scheduled_executions').notNull().default(0), + /** @deprecated Retired usage counter; derive from usage_log. */ totalChatExecutions: integer('total_chat_executions').notNull().default(0), + /** @deprecated Retired usage counter; derive from usage_log. */ totalMcpExecutions: integer('total_mcp_executions').notNull().default(0), + /** @deprecated Retired usage counter; derive from usage_log. */ totalA2aExecutions: integer('total_a2a_executions').notNull().default(0), + /** @deprecated Retired usage counter; derive from usage_log. */ totalTokensUsed: bigint('total_tokens_used', { mode: 'number' }).notNull().default(0), - /** - * Deprecated former usage hot-path cost aggregate. - * - * `recordUsage` now appends attributed rows to `usage_log`; this column is - * retained only for legacy/admin reporting until consumers move to ledger - * aggregations. - */ + /** @deprecated Not written (recordUsage appends to usage_log); legacy/admin reads only. Move readers to ledger aggregation. */ totalCost: decimal('total_cost').notNull().default('0'), currentUsageLimit: decimal('current_usage_limit').default(DEFAULT_FREE_CREDITS.toString()), // Default $5 (1,000 credits) for free plan, null for team/enterprise usageLimitUpdatedAt: timestamp('usage_limit_updated_at').defaultNow(), /** - * Deprecated former usage hot-path current-period aggregate. - * - * Keep only as the pre-shift baseline for the active billing period. - * Canonical current-period usage is `currentPeriodCost` baseline plus - * attributed `usage_log` rows for the same billing entity and period. + * Active per-period baseline (not a per-usage hot-path counter). Current usage + * = this baseline + attributed usage_log rows for the period; reset at rollover. */ - currentPeriodCost: decimal('current_period_cost').notNull().default('0'), // Usage in current billing period + currentPeriodCost: decimal('current_period_cost').notNull().default('0'), lastPeriodCost: decimal('last_period_cost').default('0'), // Usage from previous billing period /** * Threshold/final billing tracker. @@ -933,25 +949,21 @@ export const userStats = pgTable('user_stats', { * overage collection. It is not a per-usage aggregate counter. */ creditBalance: decimal('credit_balance').notNull().default('0'), - /** - * Deprecated former Copilot hot-path cost/counter aggregates. - * - * Copilot/MCP Copilot usage should be reported from `usage_log` going - * forward. Current/last period Copilot columns remain as legacy reset - * trackers until those consumers are migrated. - */ + /** @deprecated Not written; report Copilot cost from usage_log. Legacy/admin reads only. */ totalCopilotCost: decimal('total_copilot_cost').notNull().default('0'), + /** Active per-period Copilot baseline; reset at rollover (not a per-usage counter). */ currentPeriodCopilotCost: decimal('current_period_copilot_cost').notNull().default('0'), + /** Previous-period Copilot cost; set at rollover. */ lastPeriodCopilotCost: decimal('last_period_copilot_cost').default('0'), + /** @deprecated Not written; report Copilot tokens from usage_log. Legacy/admin reads only. */ totalCopilotTokens: bigint('total_copilot_tokens', { mode: 'number' }).notNull().default(0), + /** @deprecated Not written; report Copilot calls from usage_log. Legacy/admin reads only. */ totalCopilotCalls: integer('total_copilot_calls').notNull().default(0), - /** - * Deprecated former MCP Copilot hot-path aggregates. - * - * New MCP Copilot billing usage should be reported from `usage_log`. - */ + /** @deprecated Not written; report MCP Copilot calls from usage_log. Legacy/admin reads only. */ totalMcpCopilotCalls: integer('total_mcp_copilot_calls').notNull().default(0), + /** @deprecated Not written; report MCP Copilot cost from usage_log. Legacy/admin reads only. */ totalMcpCopilotCost: decimal('total_mcp_copilot_cost').notNull().default('0'), + /** @deprecated No writer (never incremented or reset). MCP copilot usage lives in usage_log (source 'mcp_copilot'); read it from there, not this column. */ currentPeriodMcpCopilotCost: decimal('current_period_mcp_copilot_cost').notNull().default('0'), /** * Storage upload/delete hot-path tracker for personal plans. @@ -960,13 +972,7 @@ export const userStats = pgTable('user_stats', { * org-scoped storage writes update `organization.storageUsedBytes`. */ storageUsedBytes: bigint('storage_used_bytes', { mode: 'number' }).notNull().default(0), - /** - * Deprecated former execution hot-path activity timestamp. - * - * Successful workflow execution no longer updates `user_stats`; this column - * is retained only for legacy/admin reporting until replaced by an activity - * source that does not contend on this row. - */ + /** @deprecated Not updated by execution (no user_stats write on completion); legacy/admin reads only. */ lastActive: timestamp('last_active').notNull().defaultNow(), billingBlocked: boolean('billing_blocked').notNull().default(false), billingBlockedReason: billingBlockedReasonEnum('billing_blocked_reason'), @@ -2791,7 +2797,7 @@ export const auditLog = pgTable( }) ) -export const usageLogCategoryEnum = pgEnum('usage_log_category', ['model', 'fixed']) +export const usageLogCategoryEnum = pgEnum('usage_log_category', ['model', 'fixed', 'tool']) export const usageLogSourceEnum = pgEnum('usage_log_source', [ 'workflow', 'wand', @@ -2861,6 +2867,7 @@ export const usageLog = pgTable( table.workspaceId, table.createdAt ), + executionIdIdx: index('usage_log_execution_id_idx').on(table.executionId), }) ) diff --git a/packages/db/scripts/migrate.ts b/packages/db/scripts/migrate.ts index d8449a5775..9d967a9db7 100644 --- a/packages/db/scripts/migrate.ts +++ b/packages/db/scripts/migrate.ts @@ -2,6 +2,33 @@ import { drizzle } from 'drizzle-orm/postgres-js' import { migrate } from 'drizzle-orm/postgres-js/migrator' import postgres from 'postgres' +/** + * Concurrent-index convention (avoid write-blocking index builds on large tables) + * -------------------------------------------------------------------------------- + * drizzle-kit emits plain `CREATE INDEX`, which takes a SHARE lock and blocks all + * writes for the build duration — on a big, write-hot table (e.g. + * workflow_execution_logs, usage_log) that stalls every in-flight workflow + * completion for minutes. drizzle wraps each migration in a transaction, and + * `CREATE INDEX CONCURRENTLY` cannot run inside a transaction block. + * + * So, after generating a migration that adds an index on a large/hot table, edit + * the generated SQL to end drizzle's transaction first, then build concurrently + * and idempotently: + * + * COMMIT;--> statement-breakpoint + * CREATE INDEX CONCURRENTLY IF NOT EXISTS "idx_name" ON "table" (...); + * + * Notes: + * - Put the `COMMIT` breakpoint AFTER all transactional DDL (ALTER TABLE/TYPE) + * in the file and only the concurrent CREATE INDEX statements below it. + * - Use `IF NOT EXISTS` (and make sibling DDL idempotent, e.g. + * `ADD COLUMN IF NOT EXISTS`, `ADD VALUE IF NOT EXISTS`) so a re-run after a + * failed CONCURRENTLY build is safe — fresh DBs and re-applies both work. + * - CONCURRENTLY only takes a SHARE UPDATE EXCLUSIVE lock (allows reads/writes). + * - Always validate on staging before prod; a failed CONCURRENTLY build can + * leave an INVALID index that must be dropped and rebuilt. + */ + const url = process.env.DATABASE_URL if (!url) { console.error('ERROR: Missing DATABASE_URL environment variable.') @@ -12,6 +39,8 @@ if (!url) { const client = postgres(url, { max: 1, connect_timeout: 10 }) try { + // statement_timeout=0: index builds (esp. CONCURRENTLY on large tables) can run + // far longer than the app default; a migration must never be killed mid-build. await client`SET statement_timeout = 0` await migrate(drizzle(client), { migrationsFolder: './migrations' }) console.log('Migrations applied successfully.') From e533f1b56eb1be1c9098ae59875d190c932ab6f7 Mon Sep 17 00:00:00 2001 From: Waleed Date: Fri, 29 May 2026 14:22:13 -0700 Subject: [PATCH 02/29] improvement(providers): harden OpenAI-compatible providers + add tests (#4796) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * improvement(providers): harden OpenAI-compatible providers + add tests * fix(vllm): let tool-loop errors propagate instead of returning silent partial success * fix(litellm): force tool_choice 'none' on final structured-output call The deferred final call used tool_choice 'auto', so the model could emit another tool_calls round instead of the structured answer, leaving content stale. Use 'none' (matching vLLM/Fireworks) on both the streaming and non-streaming final calls so the model must return the structured response. Co-Authored-By: Claude Opus 4.8 * fix(providers/ollama): drop tools from post-tool streaming call Ollama ignores tool_choice (not in its supported fields), so vLLM/Fireworks' tool_choice:'none' guard is a no-op here. Omit tools from the final streaming payload instead so the summarization turn can't emit dropped tool calls. Co-Authored-By: Claude Opus 4.8 * fix(litellm): spread payload into deferred final call so reasoning_effort carries over The non-streaming deferred finalPayload hand-picked fields and dropped reasoning_effort (and any future payload field), diverging from the streaming path which spreads ...payload. Spread payload here too for consistency. Co-Authored-By: Claude Opus 4.8 * chore(providers/ollama): restore enrichment TSDoc block Keeps parity with sibling Chat Completions providers (cerebras/mistral/xai). Co-Authored-By: Claude Opus 4.8 * docs(fireworks): restore TSDoc on utils helpers Restore the TSDoc blocks on supportsNativeStructuredOutputs, createReadableStreamFromOpenAIStream, and checkForForcedToolUsage — TSDoc is the codebase documentation standard and should not have been stripped. Co-Authored-By: Claude Opus 4.8 * chore(litellm): remove inline rationale comments (codebase uses TSDoc) Co-Authored-By: Claude Opus 4.8 * chore(providers/ollama): drop orphaned enrichment TSDoc The block documented a function that now lives in trace-enrichment.ts, so it documents nothing in this file. Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- apps/sim/providers/fireworks/index.test.ts | 226 +++++++++++++ apps/sim/providers/fireworks/index.ts | 10 +- apps/sim/providers/litellm/index.test.ts | 290 ++++++++++++++++ apps/sim/providers/litellm/index.ts | 115 ++++++- apps/sim/providers/ollama/index.test.ts | 351 ++++++++++++++++++++ apps/sim/providers/ollama/index.ts | 50 ++- apps/sim/providers/openai/core.ts | 55 +-- apps/sim/providers/openrouter/index.test.ts | 345 +++++++++++++++++++ apps/sim/providers/openrouter/index.ts | 9 +- apps/sim/providers/openrouter/utils.ts | 14 - apps/sim/providers/utils.ts | 53 +++ apps/sim/providers/vllm/index.test.ts | 296 +++++++++++++++++ apps/sim/providers/vllm/index.ts | 54 ++- apps/sim/providers/vllm/utils.ts | 15 +- 14 files changed, 1737 insertions(+), 146 deletions(-) create mode 100644 apps/sim/providers/fireworks/index.test.ts create mode 100644 apps/sim/providers/litellm/index.test.ts create mode 100644 apps/sim/providers/ollama/index.test.ts create mode 100644 apps/sim/providers/openrouter/index.test.ts create mode 100644 apps/sim/providers/vllm/index.test.ts diff --git a/apps/sim/providers/fireworks/index.test.ts b/apps/sim/providers/fireworks/index.test.ts new file mode 100644 index 0000000000..68fba04c73 --- /dev/null +++ b/apps/sim/providers/fireworks/index.test.ts @@ -0,0 +1,226 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockCreate, + mockSupportsNativeStructuredOutputs, + mockPrepareToolsWithUsageControl, + mockExecuteTool, +} = vi.hoisted(() => ({ + mockCreate: vi.fn(), + mockSupportsNativeStructuredOutputs: vi.fn(), + mockPrepareToolsWithUsageControl: vi.fn(), + mockExecuteTool: vi.fn(), +})) + +vi.mock('openai', () => ({ + default: vi.fn().mockImplementation(() => ({ + chat: { completions: { create: mockCreate } }, + })), +})) + +vi.mock('@/providers', () => ({ MAX_TOOL_ITERATIONS: 5 })) + +vi.mock('@/providers/models', () => ({ + getProviderModels: vi.fn().mockReturnValue([]), + getProviderDefaultModel: vi.fn().mockReturnValue('llama-v3p1-70b-instruct'), +})) + +vi.mock('@/providers/attachments', () => ({ + formatMessagesForProvider: vi.fn((messages) => messages), +})) + +vi.mock('@/providers/fireworks/utils', () => ({ + supportsNativeStructuredOutputs: mockSupportsNativeStructuredOutputs, + createReadableStreamFromOpenAIStream: vi.fn(() => ({}) as ReadableStream), + checkForForcedToolUsage: vi.fn(() => ({ hasUsedForcedTool: false, usedForcedTools: [] })), +})) + +vi.mock('@/providers/trace-enrichment', () => ({ + enrichLastModelSegmentFromChatCompletions: vi.fn(), +})) + +vi.mock('@/providers/utils', () => ({ + calculateCost: vi.fn().mockReturnValue({ input: 0, output: 0, total: 0 }), + generateSchemaInstructions: vi.fn(() => 'SCHEMA_INSTRUCTIONS'), + prepareToolExecution: vi.fn(() => ({ toolParams: { x: 1 }, executionParams: { x: 1 } })), + prepareToolsWithUsageControl: mockPrepareToolsWithUsageControl, + sumToolCosts: vi.fn().mockReturnValue(0), +})) + +vi.mock('@/tools', () => ({ executeTool: mockExecuteTool })) + +import { fireworksProvider } from '@/providers/fireworks/index' +import { ProviderError } from '@/providers/types' + +const textResponse = (content: string) => ({ + choices: [{ message: { content, tool_calls: [] } }], + usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 }, +}) + +const toolCallResponse = () => ({ + choices: [ + { + message: { + content: null, + tool_calls: [ + { id: 'call_1', type: 'function', function: { name: 'my_tool', arguments: '{"x":1}' } }, + ], + }, + }, + ], + usage: { prompt_tokens: 8, completion_tokens: 4, total_tokens: 12 }, +}) + +const toolDef = { + id: 'my_tool', + name: 'my_tool', + description: '', + params: {}, + parameters: { type: 'object', properties: {}, required: [] }, +} + +const callBody = (index: number) => mockCreate.mock.calls[index][0] +const lastCallBody = () => mockCreate.mock.calls.at(-1)?.[0] + +describe('fireworksProvider', () => { + beforeEach(() => { + vi.clearAllMocks() + mockSupportsNativeStructuredOutputs.mockResolvedValue(true) + mockPrepareToolsWithUsageControl.mockImplementation((tools) => ({ + tools, + toolChoice: 'auto', + forcedTools: [], + })) + mockExecuteTool.mockResolvedValue({ success: true, output: { ok: true } }) + }) + + const baseRequest = { + model: 'fireworks/llama-v3p1-70b-instruct', + systemPrompt: 'You are helpful.', + messages: [{ role: 'user' as const, content: 'Hello' }], + apiKey: 'fw-test-key', + } + + it('throws when the API key is missing', async () => { + await expect( + fireworksProvider.executeRequest({ ...baseRequest, apiKey: undefined }) + ).rejects.toThrow('API key is required for Fireworks') + }) + + it('returns content and token usage for a simple request', async () => { + mockCreate.mockResolvedValueOnce(textResponse('hi there')) + + const result = await fireworksProvider.executeRequest(baseRequest) + + expect(result).toMatchObject({ + content: 'hi there', + model: 'llama-v3p1-70b-instruct', + tokens: { input: 10, output: 5, total: 15 }, + }) + }) + + it('wraps API errors in a ProviderError', async () => { + mockCreate.mockRejectedValueOnce(new Error('boom')) + + await expect(fireworksProvider.executeRequest(baseRequest)).rejects.toBeInstanceOf( + ProviderError + ) + }) + + it('streams directly when there are no tools', async () => { + mockCreate.mockResolvedValueOnce({}) + + const result = await fireworksProvider.executeRequest({ ...baseRequest, stream: true }) + + expect(lastCallBody()).toMatchObject({ stream: true, stream_options: { include_usage: true } }) + expect(result).toHaveProperty('stream') + expect(result).toHaveProperty('execution') + }) + + it('sends a json_schema response_format with no strict field', async () => { + mockCreate.mockResolvedValueOnce(textResponse('{}')) + + await fireworksProvider.executeRequest({ + ...baseRequest, + responseFormat: { name: 'my_schema', schema: { type: 'object' }, strict: true }, + }) + + expect(lastCallBody().response_format).toEqual({ + type: 'json_schema', + json_schema: { name: 'my_schema', schema: { type: 'object' } }, + }) + expect(lastCallBody().response_format.json_schema).not.toHaveProperty('strict') + }) + + it('falls back to json_object with prompt instructions when native is unsupported', async () => { + mockSupportsNativeStructuredOutputs.mockResolvedValue(false) + mockCreate.mockResolvedValueOnce(textResponse('{}')) + + await fireworksProvider.executeRequest({ + ...baseRequest, + responseFormat: { name: 'my_schema', schema: { type: 'object' } }, + }) + + expect(lastCallBody().response_format).toEqual({ type: 'json_object' }) + expect(lastCallBody().messages.at(-1)).toEqual({ + role: 'user', + content: 'SCHEMA_INSTRUCTIONS', + }) + }) + + it('defers response_format to a final call when tools are active', async () => { + mockCreate + .mockResolvedValueOnce(textResponse('intermediate')) + .mockResolvedValueOnce(textResponse('{"done":true}')) + + await fireworksProvider.executeRequest({ + ...baseRequest, + responseFormat: { name: 'my_schema', schema: { type: 'object' } }, + tools: [toolDef], + }) + + expect(mockCreate).toHaveBeenCalledTimes(2) + expect(callBody(0).response_format).toBeUndefined() + expect(callBody(0).tools).toBeDefined() + expect(callBody(1).response_format).toEqual({ + type: 'json_schema', + json_schema: { name: 'my_schema', schema: { type: 'object' } }, + }) + expect(callBody(1).tools).toBeUndefined() + }) + + it('runs the tool loop and threads tool results back into the conversation', async () => { + mockCreate + .mockResolvedValueOnce(toolCallResponse()) + .mockResolvedValueOnce(textResponse('final answer')) + + const result = await fireworksProvider.executeRequest({ ...baseRequest, tools: [toolDef] }) + + expect(mockExecuteTool).toHaveBeenCalledWith('my_tool', { x: 1 }, expect.anything()) + expect(result).toMatchObject({ content: 'final answer' }) + expect((result as { toolCalls?: unknown[] }).toolCalls).toHaveLength(1) + + const followUpMessages = callBody(1).messages + expect(followUpMessages).toContainEqual( + expect.objectContaining({ role: 'assistant', tool_calls: expect.any(Array) }) + ) + expect(followUpMessages).toContainEqual( + expect.objectContaining({ role: 'tool', tool_call_id: 'call_1' }) + ) + }) + + it("forces tool_choice 'none' on the final streaming call after tools run", async () => { + mockCreate + .mockResolvedValueOnce(toolCallResponse()) + .mockResolvedValueOnce(textResponse('done')) + .mockResolvedValueOnce({}) + + await fireworksProvider.executeRequest({ ...baseRequest, stream: true, tools: [toolDef] }) + + expect(mockCreate).toHaveBeenCalledTimes(3) + expect(lastCallBody()).toMatchObject({ tool_choice: 'none', stream: true }) + }) +}) diff --git a/apps/sim/providers/fireworks/index.ts b/apps/sim/providers/fireworks/index.ts index 794ee3f080..cdf355d345 100644 --- a/apps/sim/providers/fireworks/index.ts +++ b/apps/sim/providers/fireworks/index.ts @@ -34,7 +34,7 @@ const logger = createLogger('FireworksProvider') /** * Applies structured output configuration to a payload based on model capabilities. - * Uses json_schema with strict mode for supported models, falls back to json_object with prompt instructions. + * Uses native json_schema for supported models, falls back to json_object with prompt instructions. */ async function applyResponseFormat( targetPayload: any, @@ -51,7 +51,6 @@ async function applyResponseFormat( json_schema: { name: responseFormat.name || 'response_schema', schema: responseFormat.schema || responseFormat, - strict: responseFormat.strict !== false, }, } return messages @@ -469,7 +468,7 @@ export const fireworksProvider: ProviderConfig = { const streamingParams: ChatCompletionCreateParamsStreaming = { ...payload, messages: [...currentMessages], - tool_choice: 'auto', + tool_choice: 'none', stream: true, stream_options: { include_usage: true }, } @@ -652,8 +651,3 @@ export const fireworksProvider: ProviderConfig = { } }, } - -/** - * Enriches the last model segment with per-iteration content from a Chat - * Completions response: assistant text, tool calls, finish reason, token usage. - */ diff --git a/apps/sim/providers/litellm/index.test.ts b/apps/sim/providers/litellm/index.test.ts new file mode 100644 index 0000000000..5261f4e23d --- /dev/null +++ b/apps/sim/providers/litellm/index.test.ts @@ -0,0 +1,290 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockCreate, mockExecuteTool } = vi.hoisted(() => ({ + mockCreate: vi.fn(), + mockExecuteTool: vi.fn(), +})) + +vi.mock('openai', () => ({ + default: vi.fn().mockImplementation(() => ({ + chat: { completions: { create: mockCreate } }, + })), +})) + +vi.mock('@/tools', () => ({ executeTool: mockExecuteTool })) + +vi.mock('@/providers', () => ({ MAX_TOOL_ITERATIONS: 20 })) + +vi.mock('@/lib/core/config/env', () => ({ + env: { LITELLM_BASE_URL: 'http://litellm.test', LITELLM_API_KEY: '' }, +})) + +vi.mock('@/stores/providers', () => ({ + useProvidersStore: { getState: () => ({ setProviderModels: vi.fn() }) }, +})) + +vi.mock('@/providers/models', () => ({ + getProviderModels: () => [], + getProviderDefaultModel: () => '', +})) + +vi.mock('@/providers/attachments', () => ({ + formatMessagesForProvider: (messages: unknown) => messages, +})) + +vi.mock('@/providers/trace-enrichment', () => ({ + enrichLastModelSegmentFromChatCompletions: vi.fn(), +})) + +vi.mock('@/providers/litellm/utils', () => ({ + createReadableStreamFromLiteLLMStream: vi.fn( + () => new ReadableStream({ start: (c) => c.close() }) + ), +})) + +vi.mock('@/providers/utils', () => ({ + calculateCost: vi.fn(() => ({ input: 0, output: 0, total: 0 })), + sumToolCosts: vi.fn(() => 0), + prepareToolExecution: vi.fn((_tool, toolArgs) => ({ + toolParams: toolArgs, + executionParams: toolArgs, + })), + prepareToolsWithUsageControl: vi.fn((tools) => ({ + tools, + toolChoice: 'auto', + forcedTools: [], + hasFilteredTools: false, + })), + trackForcedToolUsage: vi.fn(() => ({ hasUsedForcedTool: false, usedForcedTools: [] })), + enforceStrictSchema: vi.fn((schema) => ({ ...schema, additionalProperties: false })), +})) + +import { litellmProvider } from '@/providers/litellm' +import { ProviderError } from '@/providers/types' + +interface ChatOptions { + content?: string | null + toolCalls?: Array<{ id: string; function: { name: string; arguments: string } }> + usage?: { prompt_tokens: number; completion_tokens: number; total_tokens: number } +} + +function chat({ content = null, toolCalls, usage }: ChatOptions = {}) { + return { + choices: [ + { + message: { content, tool_calls: toolCalls }, + finish_reason: toolCalls ? 'tool_calls' : 'stop', + }, + ], + usage: usage ?? { prompt_tokens: 5, completion_tokens: 3, total_tokens: 8 }, + } +} + +function tool(name: string) { + return { id: name, name, description: 'd', parameters: {} } +} + +function run(request: Record) { + return litellmProvider.executeRequest!({ + model: 'litellm/llama-3', + messages: [{ role: 'user', content: 'Hi' }], + ...request, + } as never) as Promise +} + +const firstPayload = () => mockCreate.mock.calls[0][0] +const lastPayload = () => mockCreate.mock.calls.at(-1)![0] + +describe('litellmProvider.executeRequest', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCreate.mockResolvedValue(chat({ content: 'hello' })) + mockExecuteTool.mockResolvedValue({ success: true, output: { ok: true } }) + }) + + it('assembles messages, strips the model prefix, and maps params', async () => { + const result = await run({ + systemPrompt: 'You are helpful.', + context: 'Some context', + temperature: 0.5, + maxTokens: 256, + }) + + const payload = firstPayload() + expect(payload.model).toBe('llama-3') + expect(payload.messages).toEqual([ + { role: 'system', content: 'You are helpful.' }, + { role: 'user', content: 'Some context' }, + { role: 'user', content: 'Hi' }, + ]) + expect(payload.temperature).toBe(0.5) + expect(payload.max_completion_tokens).toBe(256) + expect(result.content).toBe('hello') + expect(result.tokens).toEqual({ input: 5, output: 3, total: 8 }) + }) + + it('forwards reasoning_effort only when set to a non-default value', async () => { + await run({ reasoningEffort: 'high' }) + expect(firstPayload().reasoning_effort).toBe('high') + + mockCreate.mockClear() + await run({ reasoningEffort: 'auto' }) + expect(firstPayload().reasoning_effort).toBeUndefined() + + mockCreate.mockClear() + await run({}) + expect(firstPayload().reasoning_effort).toBeUndefined() + }) + + it('sanitizes the schema for strict response_format and passes it through otherwise', async () => { + await run({ responseFormat: { name: 'r', schema: { type: 'object', properties: {} } } }) + let rf = firstPayload().response_format + expect(rf.type).toBe('json_schema') + expect(rf.json_schema.strict).toBe(true) + expect(rf.json_schema.schema.additionalProperties).toBe(false) + + mockCreate.mockClear() + await run({ + responseFormat: { name: 'r', schema: { type: 'object', properties: {} }, strict: false }, + }) + rf = firstPayload().response_format + expect(rf.json_schema.strict).toBe(false) + expect(rf.json_schema.schema.additionalProperties).toBeUndefined() + }) + + it('defers response_format past the tool loop and keeps tools on the final call', async () => { + mockCreate + .mockResolvedValueOnce( + chat({ toolCalls: [{ id: 'c1', function: { name: 'known', arguments: '{"q":1}' } }] }) + ) + .mockResolvedValueOnce(chat({ content: 'mid' })) + .mockResolvedValueOnce(chat({ content: '{"answer":1}' })) + + const result = await run({ + tools: [tool('known')], + reasoningEffort: 'high', + responseFormat: { name: 'r', schema: { type: 'object', properties: {} } }, + }) + + expect(firstPayload().response_format).toBeUndefined() + expect(firstPayload().tools).toBeDefined() + + const final = lastPayload() + expect(final.response_format.type).toBe('json_schema') + expect(final.tools).toBeDefined() + expect(final.tool_choice).toBe('none') + expect(final.parallel_tool_calls).toBe(false) + expect(final.reasoning_effort).toBe('high') + expect(result.content).toBe('{"answer":1}') + }) + + it('defers response_format into the final streaming call while keeping tools', async () => { + mockCreate + .mockResolvedValueOnce( + chat({ toolCalls: [{ id: 'c1', function: { name: 'known', arguments: '{}' } }] }) + ) + .mockResolvedValueOnce(chat({ content: 'mid' })) + + const result = await run({ + stream: true, + tools: [tool('known')], + responseFormat: { name: 'r', schema: { type: 'object', properties: {} } }, + }) + + const final = lastPayload() + expect(final.stream).toBe(true) + expect(final.response_format.type).toBe('json_schema') + expect(final.tools).toBeDefined() + expect(final.tool_choice).toBe('none') + expect(final.parallel_tool_calls).toBe(false) + expect(result.execution.isStreaming).toBe(true) + }) + + it('threads assistant tool_calls and a named tool response, and reports toolCalls', async () => { + mockCreate + .mockResolvedValueOnce( + chat({ toolCalls: [{ id: 'c1', function: { name: 'known', arguments: '{}' } }] }) + ) + .mockResolvedValueOnce(chat({ content: 'done' })) + mockExecuteTool.mockResolvedValue({ success: true, output: { temp: 72 } }) + + const result = await run({ tools: [tool('known')] }) + + const followupMessages = mockCreate.mock.calls[1][0].messages + expect(followupMessages).toContainEqual({ + role: 'assistant', + content: null, + tool_calls: [{ id: 'c1', type: 'function', function: { name: 'known', arguments: '{}' } }], + }) + expect(followupMessages).toContainEqual({ + role: 'tool', + tool_call_id: 'c1', + name: 'known', + content: JSON.stringify({ temp: 72 }), + }) + expect(result.toolCalls).toHaveLength(1) + expect(result.content).toBe('done') + }) + + it('emits a stub tool response for an unanswered tool_call_id', async () => { + mockCreate + .mockResolvedValueOnce( + chat({ toolCalls: [{ id: 'cX', function: { name: 'ghost', arguments: '{}' } }] }) + ) + .mockResolvedValueOnce(chat({ content: 'recovered' })) + + await run({ tools: [tool('known')] }) + + expect(mockExecuteTool).not.toHaveBeenCalled() + const followupMessages = mockCreate.mock.calls[1][0].messages + const toolMsg = followupMessages.find((m: any) => m.role === 'tool' && m.tool_call_id === 'cX') + expect(toolMsg).toBeDefined() + expect(toolMsg.content).toContain('not available') + }) + + it('executes a tool with empty arguments without failing', async () => { + mockCreate + .mockResolvedValueOnce( + chat({ toolCalls: [{ id: 'c1', function: { name: 'ping', arguments: '' } }] }) + ) + .mockResolvedValueOnce(chat({ content: 'pong' })) + + await run({ tools: [tool('ping')] }) + + expect(mockExecuteTool).toHaveBeenCalledTimes(1) + const toolMsg = mockCreate.mock.calls[1][0].messages.find((m: any) => m.role === 'tool') + expect(toolMsg.content).not.toContain('"error":true') + }) + + it('stops the tool loop at MAX_TOOL_ITERATIONS', async () => { + mockCreate.mockResolvedValue( + chat({ toolCalls: [{ id: 'c1', function: { name: 'known', arguments: '{}' } }] }) + ) + + await run({ tools: [tool('known')] }) + + expect(mockCreate).toHaveBeenCalledTimes(1 + 20) + expect(mockExecuteTool).toHaveBeenCalledTimes(20) + }) + + it('returns a streaming execution when streaming without active tools', async () => { + const result = await run({ stream: true }) + + expect(firstPayload().stream).toBe(true) + expect(firstPayload().stream_options).toEqual({ include_usage: true }) + expect(result.stream).toBeInstanceOf(ReadableStream) + expect(result.execution.isStreaming).toBe(true) + }) + + it('wraps API errors in a ProviderError using the error envelope message', async () => { + mockCreate.mockRejectedValue({ + error: { message: 'rate limited', type: 'rate_limit_error', code: '429' }, + }) + + await expect(run({})).rejects.toBeInstanceOf(ProviderError) + await expect(run({})).rejects.toThrow('rate limited') + }) +}) diff --git a/apps/sim/providers/litellm/index.ts b/apps/sim/providers/litellm/index.ts index 33e363f050..53a5360d2c 100644 --- a/apps/sim/providers/litellm/index.ts +++ b/apps/sim/providers/litellm/index.ts @@ -19,6 +19,7 @@ import type { import { ProviderError } from '@/providers/types' import { calculateCost, + enforceStrictSchema, prepareToolExecution, prepareToolsWithUsageControl, sumToolCosts, @@ -146,19 +147,27 @@ export const litellmProvider: ProviderConfig = { if (request.temperature !== undefined) payload.temperature = request.temperature if (request.maxTokens != null) payload.max_completion_tokens = request.maxTokens - if (request.responseFormat) { - payload.response_format = { - type: 'json_schema', - json_schema: { - name: request.responseFormat.name || 'response_schema', - schema: request.responseFormat.schema || request.responseFormat, - strict: request.responseFormat.strict !== false, - }, - } - - logger.info('Added JSON schema response format to LiteLLM request') + if (request.reasoningEffort !== undefined && request.reasoningEffort !== 'auto') { + payload.reasoning_effort = request.reasoningEffort } + const isStrictResponseFormat = request.responseFormat + ? request.responseFormat.strict !== false + : false + + const responseFormatPayload = request.responseFormat + ? { + type: 'json_schema' as const, + json_schema: { + name: request.responseFormat.name || 'response_schema', + schema: isStrictResponseFormat + ? enforceStrictSchema(request.responseFormat.schema || request.responseFormat) + : request.responseFormat.schema || request.responseFormat, + strict: isStrictResponseFormat, + }, + } + : undefined + let preparedTools: ReturnType | null = null let hasActiveTools = false @@ -184,6 +193,12 @@ export const litellmProvider: ProviderConfig = { } } + const deferResponseFormat = !!responseFormatPayload && hasActiveTools + if (responseFormatPayload && !deferResponseFormat) { + payload.response_format = responseFormatPayload + logger.info('Added JSON schema response format to LiteLLM request') + } + const providerStartTime = Date.now() const providerStartTimeISO = new Date(providerStartTime).toISOString() @@ -271,6 +286,7 @@ export const litellmProvider: ProviderConfig = { endTime: new Date().toISOString(), duration: Date.now() - providerStartTime, }, + isStreaming: true, }, } as StreamingExecution @@ -374,7 +390,9 @@ export const litellmProvider: ProviderConfig = { const toolName = toolCall.function.name try { - const toolArgs = JSON.parse(toolCall.function.arguments) + const toolArgs = toolCall.function.arguments + ? JSON.parse(toolCall.function.arguments) + : {} const tool = request.tools?.find((t) => t.id === toolName) if (!tool) return null @@ -429,6 +447,8 @@ export const litellmProvider: ProviderConfig = { })), }) + const respondedToolCallIds = new Set() + for (const settledResult of executionResults) { if (settledResult.status === 'rejected' || !settledResult.value) continue @@ -469,8 +489,24 @@ export const litellmProvider: ProviderConfig = { currentMessages.push({ role: 'tool', tool_call_id: toolCall.id, + name: toolName, content: JSON.stringify(resultContent), }) + respondedToolCallIds.add(toolCall.id) + } + + for (const tc of toolCallsInResponse) { + if (respondedToolCallIds.has(tc.id)) continue + currentMessages.push({ + role: 'tool', + tool_call_id: tc.id, + name: tc.function.name, + content: JSON.stringify({ + error: true, + message: `Tool "${tc.function.name}" is not available`, + tool: tc.function.name, + }), + }) } const thisToolsTime = Date.now() - toolsStartTime @@ -551,10 +587,14 @@ export const litellmProvider: ProviderConfig = { const streamingParams: ChatCompletionCreateParamsStreaming = { ...payload, messages: currentMessages, - tool_choice: 'auto', + tool_choice: 'none', stream: true, stream_options: { include_usage: true }, } + if (deferResponseFormat && responseFormatPayload) { + streamingParams.response_format = responseFormatPayload + streamingParams.parallel_tool_calls = false + } const streamResponse = await litellm.chat.completions.create( streamingParams, request.abortSignal ? { signal: request.abortSignal } : undefined @@ -626,12 +666,59 @@ export const litellmProvider: ProviderConfig = { endTime: new Date().toISOString(), duration: Date.now() - providerStartTime, }, + isStreaming: true, }, } as StreamingExecution return streamingResult as StreamingExecution } + if (deferResponseFormat && responseFormatPayload) { + logger.info('Applying deferred JSON schema response format after tool processing') + + const finalFormatStartTime = Date.now() + const finalPayload: any = { + ...payload, + messages: currentMessages, + response_format: responseFormatPayload, + tool_choice: 'none', + parallel_tool_calls: false, + } + + currentResponse = await litellm.chat.completions.create( + finalPayload, + request.abortSignal ? { signal: request.abortSignal } : undefined + ) + + const finalFormatEndTime = Date.now() + timeSegments.push({ + type: 'model', + name: request.model, + startTime: finalFormatStartTime, + endTime: finalFormatEndTime, + duration: finalFormatEndTime - finalFormatStartTime, + }) + modelTime += finalFormatEndTime - finalFormatStartTime + + const formattedContent = currentResponse.choices[0]?.message?.content + if (formattedContent) { + content = formattedContent.replace(/```json\n?|\n?```/g, '').trim() + } + + if (currentResponse.usage) { + tokens.input += currentResponse.usage.prompt_tokens || 0 + tokens.output += currentResponse.usage.completion_tokens || 0 + tokens.total += currentResponse.usage.total_tokens || 0 + } + + enrichLastModelSegmentFromChatCompletions( + timeSegments, + currentResponse, + currentResponse.choices[0]?.message?.tool_calls, + { model: request.model, provider: 'litellm' } + ) + } + const providerEndTime = Date.now() const providerEndTimeISO = new Date(providerEndTime).toISOString() const totalDuration = providerEndTime - providerStartTime @@ -660,7 +747,7 @@ export const litellmProvider: ProviderConfig = { let errorMessage = toError(error).message let errorType: string | undefined - let errorCode: number | undefined + let errorCode: string | number | undefined if (error && typeof error === 'object' && 'error' in error) { const litellmError = error.error as any diff --git a/apps/sim/providers/ollama/index.test.ts b/apps/sim/providers/ollama/index.test.ts new file mode 100644 index 0000000000..a6b91e9e8f --- /dev/null +++ b/apps/sim/providers/ollama/index.test.ts @@ -0,0 +1,351 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +type StreamUsage = { prompt_tokens: number; completion_tokens: number; total_tokens: number } + +const { mockCreate, mockExecuteTool, streamOnComplete, MockAPIError } = vi.hoisted(() => { + class MockAPIError extends Error { + status?: number + code?: string | null + type?: string + constructor(message: string, opts: { status?: number; code?: string; type?: string } = {}) { + super(message) + this.name = 'APIError' + this.status = opts.status + this.code = opts.code + this.type = opts.type + } + } + return { + mockCreate: vi.fn(), + mockExecuteTool: vi.fn(), + streamOnComplete: { + current: undefined as undefined | ((content: string, usage: StreamUsage) => void), + }, + MockAPIError, + } +}) + +vi.mock('openai', () => { + const OpenAI = vi.fn(() => ({ chat: { completions: { create: mockCreate } } })) + ;(OpenAI as unknown as { APIError: typeof MockAPIError }).APIError = MockAPIError + return { default: OpenAI } +}) + +vi.mock('@/lib/core/utils/urls', () => ({ getOllamaUrl: () => 'http://localhost:11434' })) +vi.mock('@/providers', () => ({ MAX_TOOL_ITERATIONS: 20 })) +vi.mock('@/providers/attachments', () => ({ + formatMessagesForProvider: (messages: unknown) => messages, +})) +vi.mock('@/providers/trace-enrichment', () => ({ + enrichLastModelSegmentFromChatCompletions: vi.fn(), +})) +vi.mock('@/providers/ollama/utils', () => ({ + createReadableStreamFromOllamaStream: ( + _stream: unknown, + onComplete: (content: string, usage: StreamUsage) => void + ) => { + streamOnComplete.current = onComplete + return 'OLLAMA_STREAM' + }, +})) +vi.mock('@/providers/utils', () => ({ + calculateCost: () => ({ input: 0, output: 0, total: 0, pricing: null }), + prepareToolExecution: (_tool: unknown, args: Record) => ({ + toolParams: args, + executionParams: args, + }), + sumToolCosts: () => 0, +})) +vi.mock('@/tools', () => ({ executeTool: mockExecuteTool })) +vi.mock('@/stores/providers', () => ({ + useProvidersStore: { getState: () => ({ setProviderModels: vi.fn() }) }, +})) + +import { ollamaProvider } from '@/providers/ollama' +import type { ProviderRequest, ProviderResponse, ProviderToolConfig } from '@/providers/types' + +interface StreamingResult { + stream: string + execution: { + output: { + content: string + tokens: { input: number; output: number; total: number } + toolCalls?: { list: unknown[]; count: number } + } + } +} + +type ToolCallChunk = { id: string; type: 'function'; function: { name: string; arguments: string } } + +function completion( + opts: { content?: string | null; toolCalls?: ToolCallChunk[]; usage?: StreamUsage } = {} +) { + return { + choices: [{ message: { content: opts.content ?? null, tool_calls: opts.toolCalls } }], + usage: opts.usage ?? { prompt_tokens: 5, completion_tokens: 3, total_tokens: 8 }, + } +} + +function makeTool(id: string, usageControl?: 'auto' | 'force' | 'none'): ProviderToolConfig { + return { + id, + name: id, + description: `${id} tool`, + params: {}, + parameters: { type: 'object', properties: {}, required: [] }, + ...(usageControl ? { usageControl } : {}), + } +} + +const baseRequest: ProviderRequest = { + model: 'llama3.2', + messages: [{ role: 'user', content: 'hi' }], +} + +describe('ollamaProvider.executeRequest', () => { + beforeEach(() => { + vi.clearAllMocks() + streamOnComplete.current = undefined + mockCreate.mockResolvedValue(completion({ content: 'hello' })) + mockExecuteTool.mockResolvedValue({ success: true, output: { ok: true } }) + }) + + it('assembles system, context, then history in order and forwards params', async () => { + const result = (await ollamaProvider.executeRequest({ + ...baseRequest, + systemPrompt: 'be nice', + context: 'ctx', + temperature: 0.5, + maxTokens: 128, + })) as ProviderResponse + + expect(result).toMatchObject({ content: 'hello', model: 'llama3.2' }) + const payload = mockCreate.mock.calls[0][0] + expect(payload.messages).toEqual([ + { role: 'system', content: 'be nice' }, + { role: 'user', content: 'ctx' }, + { role: 'user', content: 'hi' }, + ]) + expect(payload.model).toBe('llama3.2') + expect(payload.temperature).toBe(0.5) + expect(payload.max_tokens).toBe(128) + }) + + it('returns content verbatim (keeps ```json fences) when no responseFormat', async () => { + const fenced = '```json\n{"a":1}\n```' + mockCreate.mockResolvedValue(completion({ content: fenced })) + const result = (await ollamaProvider.executeRequest(baseRequest)) as ProviderResponse + expect(result.content).toBe(fenced) + }) + + it('strips ```json fences and sends a json_schema response_format when requested', async () => { + mockCreate.mockResolvedValue(completion({ content: '```json\n{"a":1}\n```' })) + const result = (await ollamaProvider.executeRequest({ + ...baseRequest, + responseFormat: { name: 'r', schema: { type: 'object' }, strict: true }, + })) as ProviderResponse + expect(result.content).toBe('{"a":1}') + expect(mockCreate.mock.calls[0][0].response_format).toMatchObject({ + type: 'json_schema', + json_schema: { name: 'r', schema: { type: 'object' }, strict: true }, + }) + }) + + it('runs the tool loop: parses string args, feeds results back, then terminates', async () => { + mockCreate + .mockResolvedValueOnce( + completion({ + toolCalls: [ + { id: 'call_1', type: 'function', function: { name: 'mytool', arguments: '{"x":1}' } }, + ], + }) + ) + .mockResolvedValueOnce(completion({ content: 'done' })) + + const result = (await ollamaProvider.executeRequest({ + ...baseRequest, + tools: [makeTool('mytool')], + })) as ProviderResponse + + expect(mockExecuteTool).toHaveBeenCalledWith('mytool', { x: 1 }, expect.anything()) + expect(mockCreate).toHaveBeenCalledTimes(2) + expect(result.content).toBe('done') + expect(result.toolCalls).toEqual([ + expect.objectContaining({ name: 'mytool', success: true, arguments: { x: 1 } }), + ]) + expect(result.toolResults).toEqual([{ ok: true }]) + + const followUp = mockCreate.mock.calls[1][0].messages + expect(followUp).toContainEqual( + expect.objectContaining({ + role: 'assistant', + content: null, + tool_calls: [ + expect.objectContaining({ + id: 'call_1', + function: { name: 'mytool', arguments: '{"x":1}' }, + }), + ], + }) + ) + expect(followUp).toContainEqual({ + role: 'tool', + tool_call_id: 'call_1', + content: JSON.stringify({ ok: true }), + }) + }) + + it('records a failed tool result without aborting the loop', async () => { + mockExecuteTool.mockResolvedValue({ success: false, error: 'boom' }) + mockCreate + .mockResolvedValueOnce( + completion({ + toolCalls: [ + { id: 'call_1', type: 'function', function: { name: 'mytool', arguments: '{}' } }, + ], + }) + ) + .mockResolvedValueOnce(completion({ content: 'recovered' })) + + const result = (await ollamaProvider.executeRequest({ + ...baseRequest, + tools: [makeTool('mytool')], + })) as ProviderResponse + + expect(result.content).toBe('recovered') + expect(result.toolCalls?.[0]).toMatchObject({ name: 'mytool', success: false }) + const toolMsg = mockCreate.mock.calls[1][0].messages.find( + (m: { role: string }) => m.role === 'tool' + ) + expect(JSON.parse(toolMsg.content)).toMatchObject({ error: true, message: 'boom' }) + }) + + it('executes parallel tool calls from a single response', async () => { + mockExecuteTool + .mockResolvedValueOnce({ success: true, output: { from: 'a' } }) + .mockResolvedValueOnce({ success: true, output: { from: 'b' } }) + mockCreate + .mockResolvedValueOnce( + completion({ + toolCalls: [ + { id: 'call_a', type: 'function', function: { name: 'a', arguments: '{}' } }, + { id: 'call_b', type: 'function', function: { name: 'b', arguments: '{}' } }, + ], + }) + ) + .mockResolvedValueOnce(completion({ content: 'summary' })) + + const result = (await ollamaProvider.executeRequest({ + ...baseRequest, + tools: [makeTool('a'), makeTool('b')], + })) as ProviderResponse + + expect(mockExecuteTool).toHaveBeenCalledTimes(2) + expect(result.toolCalls?.map((c) => c.name)).toEqual(['a', 'b']) + const toolMsgs = mockCreate.mock.calls[1][0].messages.filter( + (m: { role: string }) => m.role === 'tool' + ) + expect(toolMsgs.map((m: { tool_call_id: string }) => m.tool_call_id)).toEqual([ + 'call_a', + 'call_b', + ]) + }) + + it('filters out tools with usageControl "none"', async () => { + await ollamaProvider.executeRequest({ + ...baseRequest, + tools: [makeTool('keep'), makeTool('drop', 'none')], + }) + const sent = mockCreate.mock.calls[0][0].tools + expect(sent.map((t: { function: { name: string } }) => t.function.name)).toEqual(['keep']) + }) + + it('never forces tools (Ollama ignores tool_choice) and keeps "auto"', async () => { + await ollamaProvider.executeRequest({ ...baseRequest, tools: [makeTool('forced', 'force')] }) + const payload = mockCreate.mock.calls[0][0] + expect(payload.tool_choice).toBe('auto') + expect(payload.tools.map((t: { function: { name: string } }) => t.function.name)).toEqual([ + 'forced', + ]) + }) + + it('surfaces an OpenAI APIError message through ProviderError', async () => { + mockCreate.mockRejectedValue( + new MockAPIError('model not found', { + status: 404, + code: 'not_found', + type: 'invalid_request_error', + }) + ) + await expect(ollamaProvider.executeRequest(baseRequest)).rejects.toThrow('model not found') + }) + + it('streams content and usage when no tools are used', async () => { + const result = (await ollamaProvider.executeRequest({ + ...baseRequest, + stream: true, + })) as unknown as StreamingResult + + expect(result.stream).toBe('OLLAMA_STREAM') + expect(mockCreate.mock.calls[0][0].stream_options).toEqual({ include_usage: true }) + + streamOnComplete.current?.('streamed text', { + prompt_tokens: 4, + completion_tokens: 6, + total_tokens: 10, + }) + expect(result.execution.output.content).toBe('streamed text') + expect(result.execution.output.tokens).toMatchObject({ input: 4, output: 6, total: 10 }) + }) + + it('strips ```json fences from streamed content when responseFormat is set', async () => { + const result = (await ollamaProvider.executeRequest({ + ...baseRequest, + stream: true, + responseFormat: { name: 'r', schema: { type: 'object' }, strict: true }, + })) as unknown as StreamingResult + + streamOnComplete.current?.('```json\n{"a":1}\n```', { + prompt_tokens: 1, + completion_tokens: 2, + total_tokens: 3, + }) + expect(result.execution.output.content).toBe('{"a":1}') + }) + + it('streams the final response after a tool loop, carrying tool calls', async () => { + mockCreate + .mockResolvedValueOnce( + completion({ + toolCalls: [ + { id: 'call_1', type: 'function', function: { name: 'mytool', arguments: '{}' } }, + ], + }) + ) + .mockResolvedValueOnce(completion({ content: 'intermediate' })) + + const result = (await ollamaProvider.executeRequest({ + ...baseRequest, + stream: true, + tools: [makeTool('mytool')], + })) as unknown as StreamingResult + + expect(result.stream).toBe('OLLAMA_STREAM') + expect(mockExecuteTool).toHaveBeenCalledTimes(1) + + const finalCall = mockCreate.mock.calls[2][0] + expect(finalCall.tools).toBeUndefined() + expect(finalCall.tool_choice).toBeUndefined() + + streamOnComplete.current?.('final answer', { + prompt_tokens: 2, + completion_tokens: 4, + total_tokens: 6, + }) + expect(result.execution.output.content).toBe('final answer') + expect(result.execution.output.toolCalls).toMatchObject({ count: 1 }) + }) +}) diff --git a/apps/sim/providers/ollama/index.ts b/apps/sim/providers/ollama/index.ts index 52332aecdb..bfe7cff613 100644 --- a/apps/sim/providers/ollama/index.ts +++ b/apps/sim/providers/ollama/index.ts @@ -1,5 +1,5 @@ import { createLogger } from '@sim/logger' -import { getErrorMessage, toError } from '@sim/utils/errors' +import { getErrorMessage } from '@sim/utils/errors' import OpenAI from 'openai' import type { ChatCompletionCreateParamsStreaming } from 'openai/resources/chat/completions' import { getOllamaUrl } from '@/lib/core/utils/urls' @@ -10,6 +10,7 @@ import type { ModelsObject } from '@/providers/ollama/types' import { createReadableStreamFromOllamaStream } from '@/providers/ollama/utils' import { enrichLastModelSegmentFromChatCompletions } from '@/providers/trace-enrichment' import type { + Message, ProviderConfig, ProviderRequest, ProviderResponse, @@ -73,7 +74,7 @@ export const ollamaProvider: ProviderConfig = { baseURL: `${OLLAMA_HOST}/v1`, }) - const allMessages = [] + const allMessages: Message[] = [] if (request.systemPrompt) { allMessages.push({ @@ -92,7 +93,7 @@ export const ollamaProvider: ProviderConfig = { if (request.messages) { allMessages.push(...request.messages) } - const formattedMessages = formatMessagesForProvider(allMessages, 'ollama') + const formattedMessages = formatMessagesForProvider(allMessages, 'ollama') as Message[] const tools = request.tools?.length ? request.tools.map((tool) => ({ @@ -180,7 +181,7 @@ export const ollamaProvider: ProviderConfig = { stream: createReadableStreamFromOllamaStream(streamResponse, (content, usage) => { streamingResult.execution.output.content = content - if (content) { + if (content && request.responseFormat) { streamingResult.execution.output.content = content .replace(/```json\n?|\n?```/g, '') .trim() @@ -264,7 +265,7 @@ export const ollamaProvider: ProviderConfig = { let content = currentResponse.choices[0]?.message?.content || '' - if (content) { + if (content && request.responseFormat) { content = content.replace(/```json\n?|\n?```/g, '') content = content.trim() } @@ -295,6 +296,9 @@ export const ollamaProvider: ProviderConfig = { while (iterationCount < MAX_TOOL_ITERATIONS) { if (currentResponse.choices[0]?.message?.content) { content = currentResponse.choices[0].message.content + if (request.responseFormat) { + content = content.replace(/```json\n?|\n?```/g, '').trim() + } } const toolCallsInResponse = currentResponse.choices[0]?.message?.tool_calls @@ -450,8 +454,9 @@ export const ollamaProvider: ProviderConfig = { if (currentResponse.choices[0]?.message?.content) { content = currentResponse.choices[0].message.content - content = content.replace(/```json\n?|\n?```/g, '') - content = content.trim() + if (request.responseFormat) { + content = content.replace(/```json\n?|\n?```/g, '').trim() + } } if (currentResponse.usage) { @@ -477,10 +482,11 @@ export const ollamaProvider: ProviderConfig = { const accumulatedCost = calculateCost(request.model, tokens.input, tokens.output) + const { tools: _tools, tool_choice: _toolChoice, ...streamPayload } = payload + const streamingParams: ChatCompletionCreateParamsStreaming = { - ...payload, + ...streamPayload, messages: currentMessages, - tool_choice: 'auto', stream: true, stream_options: { include_usage: true }, } @@ -493,7 +499,7 @@ export const ollamaProvider: ProviderConfig = { stream: createReadableStreamFromOllamaStream(streamResponse, (content, usage) => { streamingResult.execution.output.content = content - if (content) { + if (content && request.responseFormat) { streamingResult.execution.output.content = content .replace(/```json\n?|\n?```/g, '') .trim() @@ -589,12 +595,27 @@ export const ollamaProvider: ProviderConfig = { const providerEndTimeISO = new Date(providerEndTime).toISOString() const totalDuration = providerEndTime - providerStartTime + let errorMessage = getErrorMessage(error, 'Unknown error') + let errorType: string | undefined + let errorCode: string | undefined + let status: number | undefined + + if (error instanceof OpenAI.APIError) { + errorMessage = error.message + errorType = error.type + errorCode = error.code ?? undefined + status = error.status + } + logger.error('Error in Ollama request:', { - error, + error: errorMessage, + errorType, + errorCode, + status, duration: totalDuration, }) - throw new ProviderError(toError(error).message, { + throw new ProviderError(errorMessage, { startTime: providerStartTimeISO, endTime: providerEndTimeISO, duration: totalDuration, @@ -602,8 +623,3 @@ export const ollamaProvider: ProviderConfig = { } }, } - -/** - * Enriches the last model segment with per-iteration content from a Chat - * Completions response: assistant text, tool calls, finish reason, token usage. - */ diff --git a/apps/sim/providers/openai/core.ts b/apps/sim/providers/openai/core.ts index 6946f0c0fa..6f19cef156 100644 --- a/apps/sim/providers/openai/core.ts +++ b/apps/sim/providers/openai/core.ts @@ -8,6 +8,7 @@ import type { Message, ProviderRequest, ProviderResponse, TimeSegment } from '@/ import { ProviderError } from '@/providers/types' import { calculateCost, + enforceStrictSchema, prepareToolExecution, prepareToolsWithUsageControl, sumToolCosts, @@ -31,60 +32,6 @@ import { type PreparedTools = ReturnType type ToolChoice = PreparedTools['toolChoice'] -/** - * Recursively enforces OpenAI strict mode requirements on a JSON schema. - * - Sets additionalProperties: false on all object types. - * - Ensures required includes ALL property keys. - */ -function enforceStrictSchema(schema: Record): Record { - if (!schema || typeof schema !== 'object') return schema - - const result = { ...schema } - - // If this is an object type, enforce strict requirements - if (result.type === 'object') { - result.additionalProperties = false - - // Recursively process properties and ensure required includes all keys - if (result.properties && typeof result.properties === 'object') { - const propKeys = Object.keys(result.properties as Record) - result.required = propKeys // Strict mode requires ALL properties - result.properties = Object.fromEntries( - Object.entries(result.properties as Record).map(([key, value]) => [ - key, - enforceStrictSchema(value as Record), - ]) - ) - } - } - - // Handle array items - if (result.type === 'array' && result.items) { - result.items = enforceStrictSchema(result.items as Record) - } - - // Handle anyOf, oneOf, allOf - for (const keyword of ['anyOf', 'oneOf', 'allOf']) { - if (Array.isArray(result[keyword])) { - result[keyword] = (result[keyword] as Record[]).map(enforceStrictSchema) - } - } - - // Handle $defs / definitions - for (const defKey of ['$defs', 'definitions']) { - if (result[defKey] && typeof result[defKey] === 'object') { - result[defKey] = Object.fromEntries( - Object.entries(result[defKey] as Record).map(([key, value]) => [ - key, - enforceStrictSchema(value as Record), - ]) - ) - } - } - - return result -} - export interface ResponsesProviderConfig { providerId: string providerLabel: string diff --git a/apps/sim/providers/openrouter/index.test.ts b/apps/sim/providers/openrouter/index.test.ts new file mode 100644 index 0000000000..88339fe4a9 --- /dev/null +++ b/apps/sim/providers/openrouter/index.test.ts @@ -0,0 +1,345 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockCreate, + mockExecuteTool, + mockSupportsNative, + mockPrepareTools, + mockCheckForced, + mockCreateStream, +} = vi.hoisted(() => ({ + mockCreate: vi.fn(), + mockExecuteTool: vi.fn(), + mockSupportsNative: vi.fn(), + mockPrepareTools: vi.fn((tools: unknown) => ({ + tools, + toolChoice: 'auto', + forcedTools: [], + hasFilteredTools: false, + })), + mockCheckForced: vi.fn(() => ({ hasUsedForcedTool: false, usedForcedTools: [] })), + mockCreateStream: vi.fn(), +})) + +vi.mock('openai', () => ({ + default: vi.fn().mockImplementation(() => ({ + chat: { completions: { create: mockCreate } }, + })), +})) + +vi.mock('@/providers', () => ({ MAX_TOOL_ITERATIONS: 10 })) + +vi.mock('@/tools', () => ({ executeTool: mockExecuteTool })) + +vi.mock('@/providers/models', () => ({ + getProviderModels: vi.fn().mockReturnValue([]), + getProviderDefaultModel: vi.fn().mockReturnValue(''), +})) + +vi.mock('@/providers/attachments', () => ({ + formatMessagesForProvider: vi.fn((messages: unknown) => messages), +})) + +vi.mock('@/providers/openrouter/utils', () => ({ + supportsNativeStructuredOutputs: mockSupportsNative, + createReadableStreamFromOpenAIStream: mockCreateStream, + checkForForcedToolUsage: mockCheckForced, +})) + +vi.mock('@/providers/trace-enrichment', () => ({ + enrichLastModelSegmentFromChatCompletions: vi.fn(), +})) + +vi.mock('@/providers/utils', () => ({ + calculateCost: vi.fn(() => ({ input: 0, output: 0, total: 0 })), + prepareToolsWithUsageControl: mockPrepareTools, + prepareToolExecution: vi.fn((_tool: unknown, toolArgs: Record) => ({ + toolParams: toolArgs, + executionParams: toolArgs, + })), + sumToolCosts: vi.fn(() => 0), + generateSchemaInstructions: vi.fn(() => 'SCHEMA_INSTRUCTIONS'), +})) + +import { openRouterProvider } from '@/providers/openrouter/index' +import type { ProviderRequest, ProviderResponse, ProviderToolConfig } from '@/providers/types' + +interface Usage { + prompt_tokens: number + completion_tokens: number + total_tokens: number +} + +function textResponse( + content: string, + usage: Usage = { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 } +) { + return { + choices: [{ message: { content, tool_calls: undefined }, finish_reason: 'stop' }], + usage, + } +} + +function toolCallResponse(name: string, args: Record, id = 'call_1') { + return { + choices: [ + { + message: { + content: null, + tool_calls: [ + { id, type: 'function', function: { name, arguments: JSON.stringify(args) } }, + ], + }, + finish_reason: 'tool_calls', + }, + ], + usage: { prompt_tokens: 8, completion_tokens: 4, total_tokens: 12 }, + } +} + +function tool(id: string): ProviderToolConfig { + return { + id, + name: id, + description: 'test tool', + params: {}, + parameters: { type: 'object', properties: {}, required: [] }, + } +} + +const baseRequest: ProviderRequest = { + apiKey: 'sk-or-test', + model: 'openrouter/anthropic/claude-3.5-sonnet', + systemPrompt: 'You are helpful.', + messages: [{ role: 'user', content: 'Hello' }], +} + +describe('openRouterProvider.executeRequest', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCreate.mockReset() + mockExecuteTool.mockReset() + mockSupportsNative.mockResolvedValue(false) + }) + + it('requires an API key', async () => { + await expect( + openRouterProvider.executeRequest({ model: 'openrouter/x', messages: [] }) + ).rejects.toThrow('API key is required for OpenRouter') + }) + + it('strips the openrouter/ prefix and returns content + tokens', async () => { + mockCreate.mockResolvedValueOnce(textResponse('Hi there')) + + const res = (await openRouterProvider.executeRequest(baseRequest)) as ProviderResponse + + expect(res.content).toBe('Hi there') + expect(res.model).toBe('anthropic/claude-3.5-sonnet') + expect(res.tokens).toEqual({ input: 10, output: 5, total: 15 }) + + const payload = mockCreate.mock.calls[0][0] + expect(payload.model).toBe('anthropic/claude-3.5-sonnet') + expect(payload.messages[0]).toEqual({ role: 'system', content: 'You are helpful.' }) + expect(payload.messages.at(-1)).toEqual({ role: 'user', content: 'Hello' }) + }) + + it('inserts context as a user message between system and history', async () => { + mockCreate.mockResolvedValueOnce(textResponse('ok')) + + await openRouterProvider.executeRequest({ ...baseRequest, context: 'CTX' }) + + const { messages } = mockCreate.mock.calls[0][0] + expect(messages[0]).toEqual({ role: 'system', content: 'You are helpful.' }) + expect(messages[1]).toEqual({ role: 'user', content: 'CTX' }) + expect(messages[2]).toEqual({ role: 'user', content: 'Hello' }) + }) + + it('forwards maxTokens as max_tokens and temperature', async () => { + mockCreate.mockResolvedValueOnce(textResponse('ok')) + + await openRouterProvider.executeRequest({ ...baseRequest, maxTokens: 256, temperature: 0.4 }) + + const payload = mockCreate.mock.calls[0][0] + expect(payload.max_tokens).toBe(256) + expect(payload.temperature).toBe(0.4) + }) + + it('runs the tool loop: executes the tool, echoes tool_calls, returns the tool result, sums tokens', async () => { + mockCreate + .mockResolvedValueOnce(toolCallResponse('get_weather', { city: 'SF' })) + .mockResolvedValueOnce( + textResponse('It is sunny', { prompt_tokens: 20, completion_tokens: 6, total_tokens: 26 }) + ) + mockExecuteTool.mockResolvedValueOnce({ success: true, output: { temp: 70 } }) + + const res = (await openRouterProvider.executeRequest({ + ...baseRequest, + tools: [tool('get_weather')], + })) as ProviderResponse + + expect(mockExecuteTool).toHaveBeenCalledWith('get_weather', { city: 'SF' }, expect.anything()) + expect(res.content).toBe('It is sunny') + expect(res.toolCalls?.[0]).toMatchObject({ + name: 'get_weather', + result: { temp: 70 }, + success: true, + }) + expect(res.toolResults).toEqual([{ temp: 70 }]) + expect(res.tokens).toEqual({ input: 28, output: 10, total: 38 }) + + const secondMessages = mockCreate.mock.calls[1][0].messages + const assistant = secondMessages.find((m: { role: string }) => m.role === 'assistant') + expect(assistant).toMatchObject({ + content: null, + tool_calls: [{ id: 'call_1', type: 'function', function: { name: 'get_weather' } }], + }) + const toolMsg = secondMessages.find((m: { role: string }) => m.role === 'tool') + expect(toolMsg).toEqual({ + role: 'tool', + tool_call_id: 'call_1', + content: JSON.stringify({ temp: 70 }), + }) + }) + + it('reports a failed tool result as an error payload to the model', async () => { + mockCreate + .mockResolvedValueOnce(toolCallResponse('get_weather', { city: 'SF' })) + .mockResolvedValueOnce(textResponse('done')) + mockExecuteTool.mockResolvedValueOnce({ success: false, output: undefined, error: 'boom' }) + + const res = (await openRouterProvider.executeRequest({ + ...baseRequest, + tools: [tool('get_weather')], + })) as ProviderResponse + + expect(res.toolResults).toBeUndefined() + expect(res.toolCalls?.[0]).toMatchObject({ success: false }) + const toolMsg = mockCreate.mock.calls[1][0].messages.find( + (m: { role: string }) => m.role === 'tool' + ) + expect(JSON.parse(toolMsg.content)).toEqual({ + error: true, + message: 'boom', + tool: 'get_weather', + }) + }) + + it('applies native structured outputs (json_schema + require_parameters) when no tools are active', async () => { + mockSupportsNative.mockResolvedValue(true) + mockCreate.mockResolvedValueOnce(textResponse('{"x":1}')) + + await openRouterProvider.executeRequest({ + ...baseRequest, + responseFormat: { + name: 'out', + schema: { type: 'object', properties: { x: { type: 'number' } } }, + strict: true, + }, + }) + + const payload = mockCreate.mock.calls[0][0] + expect(payload.response_format).toMatchObject({ + type: 'json_schema', + json_schema: { name: 'out', strict: true }, + }) + expect(payload.provider).toMatchObject({ require_parameters: true }) + }) + + it('falls back to json_object + prompt instructions when native structured outputs are unsupported', async () => { + mockSupportsNative.mockResolvedValue(false) + mockCreate.mockResolvedValueOnce(textResponse('{"x":1}')) + + await openRouterProvider.executeRequest({ + ...baseRequest, + responseFormat: { name: 'out', schema: { type: 'object' } }, + }) + + const payload = mockCreate.mock.calls[0][0] + expect(payload.response_format).toEqual({ type: 'json_object' }) + expect(payload.messages.at(-1)).toEqual({ role: 'user', content: 'SCHEMA_INSTRUCTIONS' }) + }) + + it('defers response_format until after the tool loop when tools are active', async () => { + mockSupportsNative.mockResolvedValue(true) + mockCreate + .mockResolvedValueOnce(textResponse('interim')) + .mockResolvedValueOnce(textResponse('{"x":1}')) + + const res = (await openRouterProvider.executeRequest({ + ...baseRequest, + tools: [tool('get_weather')], + responseFormat: { name: 'out', schema: { type: 'object' }, strict: true }, + })) as ProviderResponse + + const toolCall = mockCreate.mock.calls[0][0] + expect(toolCall.tools).toBeDefined() + expect(toolCall.response_format).toBeUndefined() + + const finalCall = mockCreate.mock.calls[1][0] + expect(finalCall.response_format).toMatchObject({ type: 'json_schema' }) + expect(finalCall.tools).toBeUndefined() + expect(finalCall.tool_choice).toBeUndefined() + expect(res.content).toBe('{"x":1}') + }) + + it('forces the next tool after a forced tool is used', async () => { + mockPrepareTools.mockReturnValueOnce({ + tools: [tool('a')], + toolChoice: { type: 'function', function: { name: 'a' } }, + forcedTools: ['a', 'b'], + hasFilteredTools: false, + }) + mockCheckForced.mockReturnValueOnce({ hasUsedForcedTool: true, usedForcedTools: ['a'] }) + mockCreate + .mockResolvedValueOnce(toolCallResponse('a', {})) + .mockResolvedValueOnce(textResponse('done')) + mockExecuteTool.mockResolvedValueOnce({ success: true, output: {} }) + + await openRouterProvider.executeRequest({ ...baseRequest, tools: [tool('a'), tool('b')] }) + + expect(mockCreate.mock.calls[0][0].tool_choice).toEqual({ + type: 'function', + function: { name: 'a' }, + }) + expect(mockCreate.mock.calls[1][0].tool_choice).toEqual({ + type: 'function', + function: { name: 'b' }, + }) + }) + + it('streams directly when there are no tools and sends usage opt-in', async () => { + mockCreate.mockResolvedValueOnce({}) + + const res = await openRouterProvider.executeRequest({ ...baseRequest, stream: true }) + + const payload = mockCreate.mock.calls[0][0] + expect(payload.stream).toBe(true) + expect(payload.stream_options).toEqual({ include_usage: true }) + expect(mockCreateStream).toHaveBeenCalledTimes(1) + expect(res).toHaveProperty('stream') + expect(res).toHaveProperty('execution.output.model', 'anthropic/claude-3.5-sonnet') + }) + + it('stops the tool loop at MAX_TOOL_ITERATIONS', async () => { + mockCreate.mockResolvedValue(toolCallResponse('looping', {})) + mockExecuteTool.mockResolvedValue({ success: true, output: {} }) + + const res = (await openRouterProvider.executeRequest({ + ...baseRequest, + tools: [tool('looping')], + })) as ProviderResponse + + expect(mockCreate).toHaveBeenCalledTimes(11) + expect(mockExecuteTool).toHaveBeenCalledTimes(10) + expect(res.toolCalls?.length).toBe(10) + }) + + it('wraps SDK errors in a ProviderError', async () => { + mockCreate.mockRejectedValueOnce(new Error('rate limited')) + + await expect(openRouterProvider.executeRequest(baseRequest)).rejects.toThrow('rate limited') + }) +}) diff --git a/apps/sim/providers/openrouter/index.ts b/apps/sim/providers/openrouter/index.ts index d3d2535b43..9bc180bdd1 100644 --- a/apps/sim/providers/openrouter/index.ts +++ b/apps/sim/providers/openrouter/index.ts @@ -376,8 +376,8 @@ export const openRouterProvider: ProviderConfig = { }) let resultContent: any - if (result.success) { - toolResults.push(result.output!) + if (result.success && result.output) { + toolResults.push(result.output) resultContent = result.output } else { resultContent = { @@ -653,8 +653,3 @@ export const openRouterProvider: ProviderConfig = { } }, } - -/** - * Enriches the last model segment with per-iteration content from a Chat - * Completions response: assistant text, tool calls, finish reason, token usage. - */ diff --git a/apps/sim/providers/openrouter/utils.ts b/apps/sim/providers/openrouter/utils.ts index 8d8dade827..51637f5148 100644 --- a/apps/sim/providers/openrouter/utils.ts +++ b/apps/sim/providers/openrouter/utils.ts @@ -20,9 +20,6 @@ let modelCapabilitiesCache: Map | null = null let cacheTimestamp = 0 const CACHE_TTL_MS = 5 * 60 * 1000 // 5 minutes -/** - * Fetches and caches OpenRouter model capabilities from their API. - */ async function fetchModelCapabilities(): Promise> { try { const response = await fetch('https://openrouter.ai/api/v1/models', { @@ -82,18 +79,11 @@ export async function getOpenRouterModelCapabilities( return modelCapabilitiesCache.get(normalizedId) ?? null } -/** - * Checks if a model supports native structured outputs (json_schema). - */ export async function supportsNativeStructuredOutputs(modelId: string): Promise { const capabilities = await getOpenRouterModelCapabilities(modelId) return capabilities?.supportsStructuredOutputs ?? false } -/** - * Creates a ReadableStream from an OpenRouter streaming response. - * Uses the shared OpenAI-compatible streaming utility. - */ export function createReadableStreamFromOpenAIStream( openaiStream: AsyncIterable, onComplete?: (content: string, usage: CompletionUsage) => void @@ -101,10 +91,6 @@ export function createReadableStreamFromOpenAIStream( return createOpenAICompatibleStream(openaiStream, 'OpenRouter', onComplete) } -/** - * Checks if a forced tool was used in an OpenRouter response. - * Uses the shared OpenAI-compatible forced tool usage helper. - */ export function checkForForcedToolUsage( response: any, toolChoice: string | { type: string; function?: { name: string }; name?: string; any?: any }, diff --git a/apps/sim/providers/utils.ts b/apps/sim/providers/utils.ts index 5815f0aecf..5cce6ce387 100644 --- a/apps/sim/providers/utils.ts +++ b/apps/sim/providers/utils.ts @@ -669,6 +669,59 @@ export function calculateCost( } } +/** + * Recursively enforces OpenAI strict-mode requirements on a JSON schema: + * - Sets `additionalProperties: false` on every object type. + * - Forces `required` to include ALL property keys. + * + * Required for any OpenAI-compatible backend that validates strict structured + * outputs (OpenAI, Azure OpenAI, and OpenAI routes behind proxies like LiteLLM), + * which reject schemas missing these constraints with an HTTP 400. + */ +export function enforceStrictSchema(schema: Record): Record { + if (!schema || typeof schema !== 'object') return schema + + const result = { ...schema } + + if (result.type === 'object') { + result.additionalProperties = false + + if (result.properties && typeof result.properties === 'object') { + const propKeys = Object.keys(result.properties as Record) + result.required = propKeys + result.properties = Object.fromEntries( + Object.entries(result.properties as Record).map(([key, value]) => [ + key, + enforceStrictSchema(value as Record), + ]) + ) + } + } + + if (result.type === 'array' && result.items) { + result.items = enforceStrictSchema(result.items as Record) + } + + for (const keyword of ['anyOf', 'oneOf', 'allOf']) { + if (Array.isArray(result[keyword])) { + result[keyword] = (result[keyword] as Record[]).map(enforceStrictSchema) + } + } + + for (const defKey of ['$defs', 'definitions']) { + if (result[defKey] && typeof result[defKey] === 'object') { + result[defKey] = Object.fromEntries( + Object.entries(result[defKey] as Record).map(([key, value]) => [ + key, + enforceStrictSchema(value as Record), + ]) + ) + } + } + + return result +} + /** * Sums the `cost.total` from each tool result returned during a provider tool loop. * Tool results may carry a `cost` object injected by `applyHostedKeyCostToResult`. diff --git a/apps/sim/providers/vllm/index.test.ts b/apps/sim/providers/vllm/index.test.ts new file mode 100644 index 0000000000..4477beeeda --- /dev/null +++ b/apps/sim/providers/vllm/index.test.ts @@ -0,0 +1,296 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockCreate, + mockExecuteTool, + mockPrepareTools, + mockCheckForced, + mockCreateStream, + envState, +} = vi.hoisted(() => ({ + mockCreate: vi.fn(), + mockExecuteTool: vi.fn(), + mockPrepareTools: vi.fn(), + mockCheckForced: vi.fn(), + mockCreateStream: vi.fn(), + envState: { + VLLM_BASE_URL: 'http://localhost:8000', + VLLM_API_KEY: undefined as string | undefined, + }, +})) + +vi.mock('openai', () => ({ + default: vi.fn(() => ({ chat: { completions: { create: mockCreate } } })), +})) +vi.mock('@/lib/core/config/env', () => ({ env: envState })) +vi.mock('@/providers', () => ({ MAX_TOOL_ITERATIONS: 20 })) +vi.mock('@/providers/models', () => ({ + getProviderModels: vi.fn(() => []), + getProviderDefaultModel: vi.fn(() => 'vllm/generic'), +})) +vi.mock('@/providers/attachments', () => ({ + formatMessagesForProvider: vi.fn((messages) => messages), +})) +vi.mock('@/providers/trace-enrichment', () => ({ + enrichLastModelSegmentFromChatCompletions: vi.fn(), +})) +vi.mock('@/providers/utils', () => ({ + calculateCost: vi.fn(() => ({ input: 0, output: 0, total: 0 })), + prepareToolExecution: vi.fn((_tool, args) => ({ toolParams: args, executionParams: args })), + prepareToolsWithUsageControl: mockPrepareTools, + sumToolCosts: vi.fn(() => 0), +})) +vi.mock('@/providers/vllm/utils', () => ({ + checkForForcedToolUsage: mockCheckForced, + createReadableStreamFromVLLMStream: mockCreateStream, +})) +vi.mock('@/tools', () => ({ executeTool: mockExecuteTool })) +vi.mock('@/stores/providers', () => ({ + useProvidersStore: { getState: () => ({ setProviderModels: vi.fn() }) }, +})) + +import type { ProviderToolConfig } from '@/providers/types' +import { vllmProvider } from '@/providers/vllm/index' + +interface ToolCall { + id: string + type: 'function' + function: { name: string; arguments: string } +} + +function chatResponse(content: string | null, toolCalls?: ToolCall[]) { + return { + choices: [{ message: { content, tool_calls: toolCalls } }], + usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 }, + } +} + +function makeTool(id: string): ProviderToolConfig { + return { + id, + name: id, + description: '', + params: {}, + parameters: { type: 'object', properties: {}, required: [] }, + } +} + +const toolCall = (id: string, name: string, args = '{}'): ToolCall => ({ + id, + type: 'function', + function: { name, arguments: args }, +}) + +/** Payload passed to the Nth `chat.completions.create` call. */ +const createPayload = (callIndex: number) => mockCreate.mock.calls[callIndex][0] + +describe('vllmProvider', () => { + beforeEach(() => { + vi.clearAllMocks() + envState.VLLM_BASE_URL = 'http://localhost:8000' + envState.VLLM_API_KEY = undefined + mockPrepareTools.mockReturnValue({ + tools: [{ type: 'function', function: { name: 'myTool' } }], + toolChoice: 'auto', + forcedTools: [], + hasFilteredTools: false, + }) + mockCheckForced.mockReturnValue({ hasUsedForcedTool: false, usedForcedTools: [] }) + mockCreateStream.mockReturnValue(new ReadableStream({ start: (c) => c.close() })) + mockExecuteTool.mockResolvedValue({ success: true, output: { result: 'ok' } }) + }) + + it('builds a chat payload with the vllm/ prefix stripped and messages assembled in order', async () => { + mockCreate.mockResolvedValueOnce(chatResponse('hello')) + + const result = await vllmProvider.executeRequest({ + model: 'vllm/llama-3', + systemPrompt: 'be helpful', + context: 'prior context', + messages: [{ role: 'user', content: 'hi' }], + temperature: 0.7, + maxTokens: 256, + }) + + const payload = createPayload(0) + expect(payload.model).toBe('llama-3') + expect(payload.temperature).toBe(0.7) + expect(payload.max_completion_tokens).toBe(256) + expect(payload.messages.map((m: { role: string }) => m.role)).toEqual([ + 'system', + 'user', + 'user', + ]) + expect(result.content).toBe('hello') + expect(result.tokens).toEqual({ input: 10, output: 5, total: 15 }) + }) + + it('sends response_format as json_schema with strict when a responseFormat is provided', async () => { + mockCreate.mockResolvedValueOnce(chatResponse('{}')) + + await vllmProvider.executeRequest({ + model: 'vllm/llama-3', + messages: [{ role: 'user', content: 'hi' }], + responseFormat: { name: 'out', schema: { type: 'object' }, strict: true }, + }) + + expect(createPayload(0).response_format).toEqual({ + type: 'json_schema', + json_schema: { name: 'out', schema: { type: 'object' }, strict: true }, + }) + }) + + it('strips markdown code fences from structured-output content', async () => { + mockCreate.mockResolvedValueOnce(chatResponse('```json\n{"a":1}\n```')) + + const result = await vllmProvider.executeRequest({ + model: 'vllm/llama-3', + messages: [{ role: 'user', content: 'hi' }], + responseFormat: { name: 'out', schema: { type: 'object' }, strict: true }, + }) + + expect(result.content).toBe('{"a":1}') + }) + + it('runs the tool loop: executes tools, appends assistant + tool messages, returns results', async () => { + mockCreate + .mockResolvedValueOnce(chatResponse(null, [toolCall('call_1', 'myTool', '{"x":1}')])) + .mockResolvedValueOnce(chatResponse('final answer')) + + const result = await vllmProvider.executeRequest({ + model: 'vllm/llama-3', + messages: [{ role: 'user', content: 'use a tool' }], + tools: [makeTool('myTool')], + }) + + expect(mockExecuteTool).toHaveBeenCalledWith('myTool', { x: 1 }, expect.anything()) + + const [assistantMessage, toolMessage] = createPayload(1).messages.slice(-2) + expect(assistantMessage).toMatchObject({ + role: 'assistant', + content: null, + tool_calls: [{ id: 'call_1', type: 'function', function: { name: 'myTool' } }], + }) + expect(toolMessage).toMatchObject({ role: 'tool', tool_call_id: 'call_1' }) + expect(toolMessage).not.toHaveProperty('name') + + expect(result.content).toBe('final answer') + expect(result.toolCalls).toHaveLength(1) + expect(result.toolCalls?.[0]).toMatchObject({ name: 'myTool', success: true }) + expect(result.toolResults).toHaveLength(1) + }) + + it('records a failed tool result without throwing', async () => { + mockExecuteTool.mockResolvedValueOnce({ success: false, error: 'tool blew up' }) + mockCreate + .mockResolvedValueOnce(chatResponse(null, [toolCall('call_1', 'myTool')])) + .mockResolvedValueOnce(chatResponse('done')) + + const result = await vllmProvider.executeRequest({ + model: 'vllm/llama-3', + messages: [{ role: 'user', content: 'go' }], + tools: [makeTool('myTool')], + }) + + expect(result.toolCalls?.[0]).toMatchObject({ name: 'myTool', success: false }) + const toolMessage = createPayload(1).messages.at(-1) + expect(JSON.parse(toolMessage.content)).toMatchObject({ error: true, tool: 'myTool' }) + }) + + it('surfaces a ProviderError when a follow-up model call fails mid-loop', async () => { + mockCreate + .mockResolvedValueOnce(chatResponse(null, [toolCall('call_1', 'myTool')])) + .mockRejectedValueOnce(new Error('connection reset')) + + await expect( + vllmProvider.executeRequest({ + model: 'vllm/llama-3', + messages: [{ role: 'user', content: 'go' }], + tools: [makeTool('myTool')], + }) + ).rejects.toThrow('connection reset') + + expect(mockExecuteTool).toHaveBeenCalledTimes(1) + }) + + it('cycles forced tools: forces the next forced tool after the first is used', async () => { + mockPrepareTools.mockReturnValue({ + tools: [{ type: 'function', function: { name: 'toolA' } }], + toolChoice: { type: 'function', function: { name: 'toolA' } }, + forcedTools: ['toolA', 'toolB'], + hasFilteredTools: false, + }) + mockCheckForced + .mockReturnValueOnce({ hasUsedForcedTool: true, usedForcedTools: ['toolA'] }) + .mockReturnValueOnce({ hasUsedForcedTool: true, usedForcedTools: ['toolA', 'toolB'] }) + mockCreate + .mockResolvedValueOnce(chatResponse(null, [toolCall('c1', 'toolA')])) + .mockResolvedValueOnce(chatResponse('done')) + + await vllmProvider.executeRequest({ + model: 'vllm/llama-3', + messages: [{ role: 'user', content: 'go' }], + tools: [makeTool('toolA'), makeTool('toolB')], + }) + + expect(createPayload(1).tool_choice).toEqual({ type: 'function', function: { name: 'toolB' } }) + }) + + it('streams directly when there are no tools, requesting usage in the stream', async () => { + mockCreate.mockResolvedValueOnce({}) + + const result = await vllmProvider.executeRequest({ + model: 'vllm/llama-3', + messages: [{ role: 'user', content: 'hi' }], + stream: true, + }) + + expect(mockCreate).toHaveBeenCalledTimes(1) + const payload = createPayload(0) + expect(payload.stream).toBe(true) + expect(payload.stream_options).toEqual({ include_usage: true }) + expect('stream' in result && 'execution' in result).toBe(true) + }) + + it('uses tool_choice "none" on the final streaming call after tool processing', async () => { + mockCreate.mockResolvedValueOnce(chatResponse('answer')).mockResolvedValueOnce({}) + + await vllmProvider.executeRequest({ + model: 'vllm/llama-3', + messages: [{ role: 'user', content: 'hi' }], + stream: true, + tools: [makeTool('myTool')], + }) + + const streamingPayload = createPayload(1) + expect(streamingPayload.stream).toBe(true) + expect(streamingPayload.tool_choice).toBe('none') + }) + + it('throws a ProviderError carrying the vLLM error message on API failure', async () => { + mockCreate.mockRejectedValueOnce({ + error: { message: 'bad request', type: 'invalid', code: 400 }, + }) + + await expect( + vllmProvider.executeRequest({ + model: 'vllm/llama-3', + messages: [{ role: 'user', content: 'hi' }], + }) + ).rejects.toThrow('bad request') + }) + + it('throws when no base URL is configured', async () => { + envState.VLLM_BASE_URL = '' + + await expect( + vllmProvider.executeRequest({ + model: 'vllm/llama-3', + messages: [{ role: 'user', content: 'hi' }], + }) + ).rejects.toThrow('VLLM_BASE_URL is required') + }) +}) diff --git a/apps/sim/providers/vllm/index.ts b/apps/sim/providers/vllm/index.ts index 2de3c69511..87610d9c43 100644 --- a/apps/sim/providers/vllm/index.ts +++ b/apps/sim/providers/vllm/index.ts @@ -21,9 +21,8 @@ import { prepareToolExecution, prepareToolsWithUsageControl, sumToolCosts, - trackForcedToolUsage, } from '@/providers/utils' -import { createReadableStreamFromVLLMStream } from '@/providers/vllm/utils' +import { checkForForcedToolUsage, createReadableStreamFromVLLMStream } from '@/providers/vllm/utils' import { useProvidersStore } from '@/stores/providers' import { executeTool } from '@/tools' @@ -282,25 +281,7 @@ export const vllmProvider: ProviderConfig = { const forcedTools = preparedTools?.forcedTools || [] let usedForcedTools: string[] = [] - - const checkForForcedToolUsage = ( - response: any, - toolChoice: string | { type: string; function?: { name: string }; name?: string; any?: any } - ) => { - if (typeof toolChoice === 'object' && response.choices[0]?.message?.tool_calls) { - const toolCallsResponse = response.choices[0].message.tool_calls - const result = trackForcedToolUsage( - toolCallsResponse, - toolChoice, - logger, - 'vllm', - forcedTools, - usedForcedTools - ) - hasUsedForcedTool = result.hasUsedForcedTool - usedForcedTools = result.usedForcedTools - } - } + let hasUsedForcedTool = false let currentResponse = await vllm.chat.completions.create( payload, @@ -327,8 +308,6 @@ export const vllmProvider: ProviderConfig = { let modelTime = firstResponseTime let toolsTime = 0 - let hasUsedForcedTool = false - const timeSegments: TimeSegment[] = [ { type: 'model', @@ -339,7 +318,16 @@ export const vllmProvider: ProviderConfig = { }, ] - checkForForcedToolUsage(currentResponse, originalToolChoice) + if (originalToolChoice) { + const forcedResult = checkForForcedToolUsage( + currentResponse, + originalToolChoice, + forcedTools, + usedForcedTools + ) + hasUsedForcedTool = forcedResult.hasUsedForcedTool + usedForcedTools = forcedResult.usedForcedTools + } while (iterationCount < MAX_TOOL_ITERATIONS) { if (currentResponse.choices[0]?.message?.content) { @@ -502,7 +490,16 @@ export const vllmProvider: ProviderConfig = { request.abortSignal ? { signal: request.abortSignal } : undefined ) - checkForForcedToolUsage(currentResponse, nextPayload.tool_choice) + if (nextPayload.tool_choice && typeof nextPayload.tool_choice === 'object') { + const forcedResult = checkForForcedToolUsage( + currentResponse, + nextPayload.tool_choice, + forcedTools, + usedForcedTools + ) + hasUsedForcedTool = forcedResult.hasUsedForcedTool + usedForcedTools = forcedResult.usedForcedTools + } const nextModelEndTime = Date.now() const thisModelTime = nextModelEndTime - nextModelStartTime @@ -550,7 +547,7 @@ export const vllmProvider: ProviderConfig = { const streamingParams: ChatCompletionCreateParamsStreaming = { ...payload, messages: currentMessages, - tool_choice: 'auto', + tool_choice: 'none', stream: true, stream_options: { include_usage: true }, } @@ -685,8 +682,3 @@ export const vllmProvider: ProviderConfig = { } }, } - -/** - * Enriches the last model segment with per-iteration content from a Chat - * Completions response: assistant text, tool calls, finish reason, token usage. - */ diff --git a/apps/sim/providers/vllm/utils.ts b/apps/sim/providers/vllm/utils.ts index 6f43329148..2b1db5bf55 100644 --- a/apps/sim/providers/vllm/utils.ts +++ b/apps/sim/providers/vllm/utils.ts @@ -1,6 +1,6 @@ import type { ChatCompletionChunk } from 'openai/resources/chat/completions' import type { CompletionUsage } from 'openai/resources/completions' -import { createOpenAICompatibleStream } from '@/providers/utils' +import { checkForForcedToolUsageOpenAI, createOpenAICompatibleStream } from '@/providers/utils' /** * Creates a ReadableStream from a vLLM streaming response. @@ -12,3 +12,16 @@ export function createReadableStreamFromVLLMStream( ): ReadableStream { return createOpenAICompatibleStream(vllmStream, 'vLLM', onComplete) } + +/** + * Checks if a forced tool was used in a vLLM response. + * Uses the shared OpenAI-compatible forced tool usage helper. + */ +export function checkForForcedToolUsage( + response: any, + toolChoice: string | { type: string; function?: { name: string }; name?: string; any?: any }, + forcedTools: string[], + usedForcedTools: string[] +): { hasUsedForcedTool: boolean; usedForcedTools: string[] } { + return checkForForcedToolUsageOpenAI(response, toolChoice, 'vLLM', forcedTools, usedForcedTools) +} From 4b0dab4682dcb5f723c7222075553fcca93eeb61 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Fri, 29 May 2026 14:34:15 -0700 Subject: [PATCH 03/29] chore(copilot): deprecate mcp server (#4797) * chore(copilot): deprecate mcp * update error codes * deprecate copilot api v1 route --- apps/docs/content/docs/de/copilot/index.mdx | 95 --- apps/docs/content/docs/en/copilot/index.mdx | 84 -- apps/docs/content/docs/es/copilot/index.mdx | 95 --- apps/docs/content/docs/fr/copilot/index.mdx | 95 --- apps/docs/content/docs/ja/copilot/index.mdx | 95 --- apps/docs/content/docs/zh/copilot/index.mdx | 95 --- .../api/mcp/copilot/route.ts | 4 +- .../api/mcp/copilot/route.ts | 4 +- .../oauth-authorization-server/route.ts | 12 +- .../oauth-protected-resource/route.ts | 12 +- apps/sim/app/api/mcp/copilot/route.test.ts | 62 ++ apps/sim/app/api/mcp/copilot/route.ts | 741 +----------------- .../sim/app/api/v1/copilot/chat/route.test.ts | 26 + apps/sim/app/api/v1/copilot/chat/route.ts | 157 +--- apps/sim/lib/mcp/copilot-deprecated.ts | 35 + packages/db/.env.swp | Bin 0 -> 12288 bytes scripts/check-api-validation-contracts.ts | 9 +- 17 files changed, 158 insertions(+), 1463 deletions(-) create mode 100644 apps/sim/app/api/mcp/copilot/route.test.ts create mode 100644 apps/sim/app/api/v1/copilot/chat/route.test.ts create mode 100644 apps/sim/lib/mcp/copilot-deprecated.ts create mode 100644 packages/db/.env.swp diff --git a/apps/docs/content/docs/de/copilot/index.mdx b/apps/docs/content/docs/de/copilot/index.mdx index 116b107bee..e9a92f7e20 100644 --- a/apps/docs/content/docs/de/copilot/index.mdx +++ b/apps/docs/content/docs/de/copilot/index.mdx @@ -246,98 +246,3 @@ Die Copilot-Nutzung wird pro Token des zugrunde liegenden LLM abgerechnet. Wenn Siehe die [Seite zur Kostenberechnung](/execution/costs) für Abrechnungsdetails. -## Copilot MCP - -Sie können Copilot als MCP-Server in Ihrem bevorzugten Editor oder AI-Client verwenden. Damit können Sie Sim-Workflows direkt aus Tools wie Cursor, Claude Code, Claude Desktop und VS Code erstellen, testen, bereitstellen und verwalten. - -### Generieren eines Copilot-API-Schlüssels - -Um sich mit dem Copilot-MCP-Server zu verbinden, benötigen Sie einen **Copilot-API-Schlüssel**: - -1. Gehen Sie zu [sim.ai](https://sim.ai) und melden Sie sich an -2. Navigieren Sie zu **Einstellungen** → **Copilot** -3. Klicken Sie auf **API-Schlüssel generieren** -4. Kopieren Sie den Schlüssel – er wird nur einmal angezeigt - -Der Schlüssel sieht aus wie `sk-sim-copilot-...`. Sie werden ihn in der folgenden Konfiguration verwenden. - -### Cursor - -Fügen Sie Folgendes zu Ihrer `.cursor/mcp.json` (Projektebene) oder den globalen Cursor-MCP-Einstellungen hinzu: - -```json -{ - "mcpServers": { - "sim-copilot": { - "url": "https://www.sim.ai/api/mcp/copilot", - "headers": { - "X-API-Key": "YOUR_COPILOT_API_KEY" - } - } - } -} -``` - -Ersetzen Sie `YOUR_COPILOT_API_KEY` durch den oben generierten Schlüssel. - -### Claude Code - -Führen Sie den folgenden Befehl aus, um den Copilot MCP-Server hinzuzufügen: - -```bash -claude mcp add sim-copilot \ - --transport http \ - https://www.sim.ai/api/mcp/copilot \ - --header "X-API-Key: YOUR_COPILOT_API_KEY" -``` - -Ersetzen Sie `YOUR_COPILOT_API_KEY` durch Ihren Schlüssel. - -### Claude Desktop - -Claude Desktop benötigt [`mcp-remote`](https://www.npmjs.com/package/mcp-remote), um sich mit HTTP-basierten MCP-Servern zu verbinden. Fügen Sie Folgendes zu Ihrer Claude Desktop-Konfigurationsdatei hinzu (`~/Library/Application Support/Claude/claude_desktop_config.json` unter macOS): - -```json -{ - "mcpServers": { - "sim-copilot": { - "command": "npx", - "args": [ - "-y", - "mcp-remote", - "https://www.sim.ai/api/mcp/copilot", - "--header", - "X-API-Key: YOUR_COPILOT_API_KEY" - ] - } - } -} -``` - -Ersetzen Sie `YOUR_COPILOT_API_KEY` durch Ihren Schlüssel. - -### VS Code - -Fügen Sie Folgendes zu Ihrer VS Code `settings.json` oder Workspace `.vscode/settings.json` hinzu: - -```json -{ - "mcp": { - "servers": { - "sim-copilot": { - "type": "http", - "url": "https://www.sim.ai/api/mcp/copilot", - "headers": { - "X-API-Key": "YOUR_COPILOT_API_KEY" - } - } - } - } -} -``` - -Ersetzen Sie `YOUR_COPILOT_API_KEY` durch Ihren Schlüssel. - - - Für selbst gehostete Deployments ersetzen Sie `https://www.sim.ai` durch Ihre selbst gehostete Sim-URL. - diff --git a/apps/docs/content/docs/en/copilot/index.mdx b/apps/docs/content/docs/en/copilot/index.mdx index 81f6dfe6a6..f30f3da691 100644 --- a/apps/docs/content/docs/en/copilot/index.mdx +++ b/apps/docs/content/docs/en/copilot/index.mdx @@ -50,90 +50,6 @@ For complex requests, Copilot may show its reasoning in an expandable thinking b Copilot usage is billed per token and counts toward your plan's credit usage. If you reach your limit, enable on-demand billing from Settings → Subscription. -## Copilot MCP - -You can use Copilot as an MCP server to build, test, and manage Sim workflows from external editors — Cursor, Claude Code, Claude Desktop, and VS Code. - -### Generating a Copilot API Key - -1. Go to [sim.ai](https://sim.ai) and sign in -2. Navigate to **Settings** → **Copilot** -3. Click **Generate API Key** -4. Copy the key — it is only shown once - -The key will look like `sk-sim-copilot-...`. - -### Cursor - -Add to `.cursor/mcp.json`: - -```json -{ - "mcpServers": { - "sim-copilot": { - "url": "https://www.sim.ai/api/mcp/copilot", - "headers": { - "X-API-Key": "YOUR_COPILOT_API_KEY" - } - } - } -} -``` - -### Claude Code - -```bash -claude mcp add sim-copilot \ - --transport http \ - https://www.sim.ai/api/mcp/copilot \ - --header "X-API-Key: YOUR_COPILOT_API_KEY" -``` - -### Claude Desktop - -Claude Desktop requires [`mcp-remote`](https://www.npmjs.com/package/mcp-remote). Add to `~/Library/Application Support/Claude/claude_desktop_config.json`: - -```json -{ - "mcpServers": { - "sim-copilot": { - "command": "npx", - "args": [ - "-y", - "mcp-remote", - "https://www.sim.ai/api/mcp/copilot", - "--header", - "X-API-Key: YOUR_COPILOT_API_KEY" - ] - } - } -} -``` - -### VS Code - -Add to `settings.json` or `.vscode/settings.json`: - -```json -{ - "mcp": { - "servers": { - "sim-copilot": { - "type": "http", - "url": "https://www.sim.ai/api/mcp/copilot", - "headers": { - "X-API-Key": "YOUR_COPILOT_API_KEY" - } - } - } - } -} -``` - - - For self-hosted deployments, replace `https://www.sim.ai` with your self-hosted Sim URL. - - Consulta la [página de cálculo de costos](/execution/costs) para detalles de facturación. -## Copilot MCP - -Puedes usar Copilot como servidor MCP en tu editor o cliente de IA favorito. Esto te permite construir, probar, desplegar y gestionar flujos de trabajo de Sim directamente desde herramientas como Cursor, Claude Code, Claude Desktop y VS Code. - -### Generar una clave API de Copilot - -Para conectarte al servidor MCP de Copilot, necesitas una **clave API de Copilot**: - -1. Ve a [sim.ai](https://sim.ai) e inicia sesión -2. Navega a **Configuración** → **Copilot** -3. Haz clic en **Generar clave API** -4. Copia la clave — solo se muestra una vez - -La clave se verá como `sk-sim-copilot-...`. Usarás esto en la configuración a continuación. - -### Cursor - -Agrega lo siguiente a tu `.cursor/mcp.json` (nivel de proyecto) o configuración global de MCP de Cursor: - -```json -{ - "mcpServers": { - "sim-copilot": { - "url": "https://www.sim.ai/api/mcp/copilot", - "headers": { - "X-API-Key": "YOUR_COPILOT_API_KEY" - } - } - } -} -``` - -Reemplaza `YOUR_COPILOT_API_KEY` con la clave que generaste anteriormente. - -### Claude Code - -Ejecuta el siguiente comando para añadir el servidor MCP de Copilot: - -```bash -claude mcp add sim-copilot \ - --transport http \ - https://www.sim.ai/api/mcp/copilot \ - --header "X-API-Key: YOUR_COPILOT_API_KEY" -``` - -Reemplaza `YOUR_COPILOT_API_KEY` con tu clave. - -### Claude Desktop - -Claude Desktop requiere [`mcp-remote`](https://www.npmjs.com/package/mcp-remote) para conectarse a servidores MCP basados en HTTP. Añade lo siguiente a tu archivo de configuración de Claude Desktop (`~/Library/Application Support/Claude/claude_desktop_config.json` en macOS): - -```json -{ - "mcpServers": { - "sim-copilot": { - "command": "npx", - "args": [ - "-y", - "mcp-remote", - "https://www.sim.ai/api/mcp/copilot", - "--header", - "X-API-Key: YOUR_COPILOT_API_KEY" - ] - } - } -} -``` - -Reemplaza `YOUR_COPILOT_API_KEY` con tu clave. - -### VS Code - -Añade lo siguiente a tu `settings.json` de VS Code o al `.vscode/settings.json` del espacio de trabajo: - -```json -{ - "mcp": { - "servers": { - "sim-copilot": { - "type": "http", - "url": "https://www.sim.ai/api/mcp/copilot", - "headers": { - "X-API-Key": "YOUR_COPILOT_API_KEY" - } - } - } - } -} -``` - -Reemplaza `YOUR_COPILOT_API_KEY` con tu clave. - - - Para implementaciones auto-alojadas, reemplaza `https://www.sim.ai` con tu URL de Sim auto-alojada. - diff --git a/apps/docs/content/docs/fr/copilot/index.mdx b/apps/docs/content/docs/fr/copilot/index.mdx index b092b26f08..53dc6c352c 100644 --- a/apps/docs/content/docs/fr/copilot/index.mdx +++ b/apps/docs/content/docs/fr/copilot/index.mdx @@ -246,98 +246,3 @@ L'utilisation de Copilot est facturée par jeton du LLM sous-jacent. Si vous att Consultez la [page de calcul des coûts](/execution/costs) pour les détails de facturation. -## Copilot MCP - -Vous pouvez utiliser Copilot comme serveur MCP dans votre éditeur ou client IA préféré. Cela vous permet de créer, tester, déployer et gérer des workflows Sim directement depuis des outils comme Cursor, Claude Code, Claude Desktop et VS Code. - -### Générer une clé API Copilot - -Pour vous connecter au serveur MCP Copilot, vous avez besoin d'une **clé API Copilot** : - -1. Rendez-vous sur [sim.ai](https://sim.ai) et connectez-vous -2. Accédez à **Paramètres** → **Copilot** -3. Cliquez sur **Générer une clé API** -4. Copiez la clé — elle n'est affichée qu'une seule fois - -La clé ressemblera à `sk-sim-copilot-...`. Vous l'utiliserez dans la configuration ci-dessous. - -### Cursor - -Ajoutez ce qui suit à votre `.cursor/mcp.json` (niveau projet) ou aux paramètres MCP globaux de Cursor : - -```json -{ - "mcpServers": { - "sim-copilot": { - "url": "https://www.sim.ai/api/mcp/copilot", - "headers": { - "X-API-Key": "YOUR_COPILOT_API_KEY" - } - } - } -} -``` - -Remplacez `YOUR_COPILOT_API_KEY` par la clé que vous avez générée ci-dessus. - -### Claude Code - -Exécutez la commande suivante pour ajouter le serveur MCP Copilot : - -```bash -claude mcp add sim-copilot \ - --transport http \ - https://www.sim.ai/api/mcp/copilot \ - --header "X-API-Key: YOUR_COPILOT_API_KEY" -``` - -Remplacez `YOUR_COPILOT_API_KEY` par votre clé. - -### Claude Desktop - -Claude Desktop nécessite [`mcp-remote`](https://www.npmjs.com/package/mcp-remote) pour se connecter aux serveurs MCP basés sur HTTP. Ajoutez ce qui suit à votre fichier de configuration Claude Desktop (`~/Library/Application Support/Claude/claude_desktop_config.json` sur macOS) : - -```json -{ - "mcpServers": { - "sim-copilot": { - "command": "npx", - "args": [ - "-y", - "mcp-remote", - "https://www.sim.ai/api/mcp/copilot", - "--header", - "X-API-Key: YOUR_COPILOT_API_KEY" - ] - } - } -} -``` - -Remplacez `YOUR_COPILOT_API_KEY` par votre clé. - -### VS Code - -Ajoutez ce qui suit à votre `settings.json` VS Code ou à votre `.vscode/settings.json` d'espace de travail : - -```json -{ - "mcp": { - "servers": { - "sim-copilot": { - "type": "http", - "url": "https://www.sim.ai/api/mcp/copilot", - "headers": { - "X-API-Key": "YOUR_COPILOT_API_KEY" - } - } - } - } -} -``` - -Remplacez `YOUR_COPILOT_API_KEY` par votre clé. - - - Pour les déploiements auto-hébergés, remplacez `https://www.sim.ai` par votre URL Sim auto-hébergée. - diff --git a/apps/docs/content/docs/ja/copilot/index.mdx b/apps/docs/content/docs/ja/copilot/index.mdx index 4714411d6b..d38f9a42cb 100644 --- a/apps/docs/content/docs/ja/copilot/index.mdx +++ b/apps/docs/content/docs/ja/copilot/index.mdx @@ -246,98 +246,3 @@ Copilotの使用量は、基盤となるLLMからのトークン単位で課金 課金の詳細については、[コスト計算ページ](/execution/costs)を参照してください。 -## Copilot MCP - -お気に入りのエディタやAIクライアントで、CopilotをMCPサーバーとして使用できます。これにより、Cursor、Claude Code、Claude Desktop、VS Codeなどのツールから直接、Simワークフローの構築、テスト、デプロイ、管理が可能になります。 - -### Copilot APIキーの生成 - -Copilot MCPサーバーに接続するには、**Copilot APIキー**が必要です。 - -1. [sim.ai](https://sim.ai)にアクセスしてサインイン -2. **設定** → **Copilot**に移動 -3. **APIキーを生成**をクリック -4. キーをコピー(一度のみ表示されます) - -キーは`sk-sim-copilot-...`のような形式です。以下の設定で使用します。 - -### Cursor - -`.cursor/mcp.json`(プロジェクトレベル)またはグローバルなCursor MCP設定に以下を追加してください。 - -```json -{ - "mcpServers": { - "sim-copilot": { - "url": "https://www.sim.ai/api/mcp/copilot", - "headers": { - "X-API-Key": "YOUR_COPILOT_API_KEY" - } - } - } -} -``` - -`YOUR_COPILOT_API_KEY`を上記で生成したキーに置き換えてください。 - -### Claude Code - -次のコマンドを実行してCopilot MCPサーバーを追加します: - -```bash -claude mcp add sim-copilot \ - --transport http \ - https://www.sim.ai/api/mcp/copilot \ - --header "X-API-Key: YOUR_COPILOT_API_KEY" -``` - -`YOUR_COPILOT_API_KEY`をあなたのキーに置き換えてください。 - -### Claude Desktop - -Claude DesktopはHTTPベースのMCPサーバーに接続するために[`mcp-remote`](https://www.npmjs.com/package/mcp-remote)が必要です。Claude Desktopの設定ファイル(macOSでは`~/Library/Application Support/Claude/claude_desktop_config.json`)に以下を追加してください: - -```json -{ - "mcpServers": { - "sim-copilot": { - "command": "npx", - "args": [ - "-y", - "mcp-remote", - "https://www.sim.ai/api/mcp/copilot", - "--header", - "X-API-Key: YOUR_COPILOT_API_KEY" - ] - } - } -} -``` - -`YOUR_COPILOT_API_KEY`をあなたのキーに置き換えてください。 - -### VS Code - -VS Codeの`settings.json`またはワークスペースの`.vscode/settings.json`に以下を追加してください: - -```json -{ - "mcp": { - "servers": { - "sim-copilot": { - "type": "http", - "url": "https://www.sim.ai/api/mcp/copilot", - "headers": { - "X-API-Key": "YOUR_COPILOT_API_KEY" - } - } - } - } -} -``` - -`YOUR_COPILOT_API_KEY`をあなたのキーに置き換えてください。 - - - セルフホスト型デプロイメントの場合は、`https://www.sim.ai`をあなたのセルフホスト型SimのURLに置き換えてください。 - diff --git a/apps/docs/content/docs/zh/copilot/index.mdx b/apps/docs/content/docs/zh/copilot/index.mdx index b3b54d134c..b37b1cdc6d 100644 --- a/apps/docs/content/docs/zh/copilot/index.mdx +++ b/apps/docs/content/docs/zh/copilot/index.mdx @@ -246,98 +246,3 @@ Copilot 的使用按底层 LLM 的 token 计费。如果达到使用上限,Cop 计费详情请参见 [成本计算页面](/execution/costs)。 -## Copilot MCP - -你可以在常用编辑器或 AI 客户端中将 Copilot 作为 MCP 服务器使用。这样可以直接在 Cursor、Claude Code、Claude Desktop 和 VS Code 等工具中构建、测试、部署和管理 Sim 工作流。 - -### 生成 Copilot API 密钥 - -要连接 Copilot MCP 服务器,你需要一个 **Copilot API 密钥**: - -1. 访问 [sim.ai](https://sim.ai) 并登录 -2. 进入 **设置** → **Copilot** -3. 点击 **生成 API 密钥** -4. 复制密钥 — 该密钥只显示一次 - -密钥类似于 `sk-sim-copilot-...`。你将在下方配置中用到它。 - -### Cursor - -将以下内容添加到你的 `.cursor/mcp.json`(项目级)或全局 Cursor MCP 设置中: - -```json -{ - "mcpServers": { - "sim-copilot": { - "url": "https://www.sim.ai/api/mcp/copilot", - "headers": { - "X-API-Key": "YOUR_COPILOT_API_KEY" - } - } - } -} -``` - -将 `YOUR_COPILOT_API_KEY` 替换为你上面生成的密钥。 - -### Claude Code - -运行以下命令以添加 Copilot MCP 服务器: - -```bash -claude mcp add sim-copilot \ - --transport http \ - https://www.sim.ai/api/mcp/copilot \ - --header "X-API-Key: YOUR_COPILOT_API_KEY" -``` - -将 `YOUR_COPILOT_API_KEY` 替换为你的密钥。 - -### Claude Desktop - -Claude Desktop 需要 [`mcp-remote`](https://www.npmjs.com/package/mcp-remote) 以连接基于 HTTP 的 MCP 服务器。请将以下内容添加到你的 Claude Desktop 配置文件(macOS 上为 `~/Library/Application Support/Claude/claude_desktop_config.json`): - -```json -{ - "mcpServers": { - "sim-copilot": { - "command": "npx", - "args": [ - "-y", - "mcp-remote", - "https://www.sim.ai/api/mcp/copilot", - "--header", - "X-API-Key: YOUR_COPILOT_API_KEY" - ] - } - } -} -``` - -将 `YOUR_COPILOT_API_KEY` 替换为你的密钥。 - -### VS Code - -请将以下内容添加到你的 VS Code `settings.json` 或工作区 `.vscode/settings.json`: - -```json -{ - "mcp": { - "servers": { - "sim-copilot": { - "type": "http", - "url": "https://www.sim.ai/api/mcp/copilot", - "headers": { - "X-API-Key": "YOUR_COPILOT_API_KEY" - } - } - } - } -} -``` - -将 `YOUR_COPILOT_API_KEY` 替换为你的密钥。 - - - 对于自托管部署,请将 `https://www.sim.ai` 替换为你的自托管 Sim URL。 - diff --git a/apps/sim/app/.well-known/oauth-authorization-server/api/mcp/copilot/route.ts b/apps/sim/app/.well-known/oauth-authorization-server/api/mcp/copilot/route.ts index d862fe277f..55d1b90a95 100644 --- a/apps/sim/app/.well-known/oauth-authorization-server/api/mcp/copilot/route.ts +++ b/apps/sim/app/.well-known/oauth-authorization-server/api/mcp/copilot/route.ts @@ -1,6 +1,6 @@ import type { NextResponse } from 'next/server' -import { createMcpAuthorizationServerMetadataResponse } from '@/lib/mcp/oauth-discovery' +import { copilotMcpDeprecatedResponse } from '@/lib/mcp/copilot-deprecated' export async function GET(): Promise { - return createMcpAuthorizationServerMetadataResponse() + return copilotMcpDeprecatedResponse() } diff --git a/apps/sim/app/.well-known/oauth-protected-resource/api/mcp/copilot/route.ts b/apps/sim/app/.well-known/oauth-protected-resource/api/mcp/copilot/route.ts index a419ebda32..55d1b90a95 100644 --- a/apps/sim/app/.well-known/oauth-protected-resource/api/mcp/copilot/route.ts +++ b/apps/sim/app/.well-known/oauth-protected-resource/api/mcp/copilot/route.ts @@ -1,6 +1,6 @@ import type { NextResponse } from 'next/server' -import { createMcpProtectedResourceMetadataResponse } from '@/lib/mcp/oauth-discovery' +import { copilotMcpDeprecatedResponse } from '@/lib/mcp/copilot-deprecated' export async function GET(): Promise { - return createMcpProtectedResourceMetadataResponse() + return copilotMcpDeprecatedResponse() } diff --git a/apps/sim/app/api/mcp/copilot/.well-known/oauth-authorization-server/route.ts b/apps/sim/app/api/mcp/copilot/.well-known/oauth-authorization-server/route.ts index 3f2976ff02..c54b72dfcb 100644 --- a/apps/sim/app/api/mcp/copilot/.well-known/oauth-authorization-server/route.ts +++ b/apps/sim/app/api/mcp/copilot/.well-known/oauth-authorization-server/route.ts @@ -1,12 +1,4 @@ -import type { NextRequest, NextResponse } from 'next/server' -import { mcpOauthAuthorizationServerMetadataContract } from '@/lib/api/contracts/mcp-oauth' -import { parseRequest } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { createMcpAuthorizationServerMetadataResponse } from '@/lib/mcp/oauth-discovery' +import { copilotMcpDeprecatedResponse } from '@/lib/mcp/copilot-deprecated' -export const GET = withRouteHandler(async (request: NextRequest): Promise => { - const parsed = await parseRequest(mcpOauthAuthorizationServerMetadataContract, request, {}) - if (!parsed.success) return parsed.response as NextResponse - - return createMcpAuthorizationServerMetadataResponse() -}) +export const GET = withRouteHandler(async () => copilotMcpDeprecatedResponse()) diff --git a/apps/sim/app/api/mcp/copilot/.well-known/oauth-protected-resource/route.ts b/apps/sim/app/api/mcp/copilot/.well-known/oauth-protected-resource/route.ts index 1e17b126b3..c54b72dfcb 100644 --- a/apps/sim/app/api/mcp/copilot/.well-known/oauth-protected-resource/route.ts +++ b/apps/sim/app/api/mcp/copilot/.well-known/oauth-protected-resource/route.ts @@ -1,12 +1,4 @@ -import type { NextRequest, NextResponse } from 'next/server' -import { mcpOauthProtectedResourceMetadataContract } from '@/lib/api/contracts/mcp-oauth' -import { parseRequest } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { createMcpProtectedResourceMetadataResponse } from '@/lib/mcp/oauth-discovery' +import { copilotMcpDeprecatedResponse } from '@/lib/mcp/copilot-deprecated' -export const GET = withRouteHandler(async (request: NextRequest): Promise => { - const parsed = await parseRequest(mcpOauthProtectedResourceMetadataContract, request, {}) - if (!parsed.success) return parsed.response as NextResponse - - return createMcpProtectedResourceMetadataResponse() -}) +export const GET = withRouteHandler(async () => copilotMcpDeprecatedResponse()) diff --git a/apps/sim/app/api/mcp/copilot/route.test.ts b/apps/sim/app/api/mcp/copilot/route.test.ts new file mode 100644 index 0000000000..7db9860fbc --- /dev/null +++ b/apps/sim/app/api/mcp/copilot/route.test.ts @@ -0,0 +1,62 @@ +/** + * Tests for the deprecated Copilot MCP route + * + * @vitest-environment node + */ +import { NextRequest } from 'next/server' +import { describe, expect, it } from 'vitest' +import { GET as authServerDiscoveryGET } from '@/app/api/mcp/copilot/.well-known/oauth-authorization-server/route' +import { GET as protectedResourceDiscoveryGET } from '@/app/api/mcp/copilot/.well-known/oauth-protected-resource/route' +import { DELETE, GET, POST } from '@/app/api/mcp/copilot/route' + +const URL = 'http://localhost:3000/api/mcp/copilot' + +describe('Deprecated Copilot MCP route', () => { + it('GET returns 410', async () => { + const response = await GET(new NextRequest(URL)) + expect(response.status).toBe(410) + }) + + it('POST returns 410 with a JSON-RPC error envelope', async () => { + const request = new NextRequest(URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize' }), + }) + const response = await POST(request) + expect(response.status).toBe(410) + + const body = (await response.json()) as { jsonrpc?: string; error?: { message?: string } } + expect(body.jsonrpc).toBe('2.0') + expect(body.error?.message).toContain('deprecated') + }) + + it('POST still returns 410 when an x-api-key header is present', async () => { + const request = new NextRequest(URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'x-api-key': 'sk-sim-copilot-test' }, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list' }), + }) + const response = await POST(request) + expect(response.status).toBe(410) + }) + + it('DELETE returns 410', async () => { + const response = await DELETE(new NextRequest(URL, { method: 'DELETE' })) + expect(response.status).toBe(410) + }) + + it('copilot OAuth authorization-server discovery returns 410', async () => { + const response = await authServerDiscoveryGET( + new NextRequest(`${URL}/.well-known/oauth-authorization-server`) + ) + expect(response.status).toBe(410) + }) + + it('copilot OAuth protected-resource discovery returns 410', async () => { + const response = await protectedResourceDiscoveryGET( + new NextRequest(`${URL}/.well-known/oauth-protected-resource`) + ) + expect(response.status).toBe(410) + }) +}) diff --git a/apps/sim/app/api/mcp/copilot/route.ts b/apps/sim/app/api/mcp/copilot/route.ts index 2bda0f3670..6ec1475d2e 100644 --- a/apps/sim/app/api/mcp/copilot/route.ts +++ b/apps/sim/app/api/mcp/copilot/route.ts @@ -1,740 +1,13 @@ -import { Server } from '@modelcontextprotocol/sdk/server/index.js' -import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js' -import { - CallToolRequestSchema, - type CallToolResult, - ErrorCode, - type JSONRPCError, - ListToolsRequestSchema, - type ListToolsResult, - McpError, - type RequestId, -} from '@modelcontextprotocol/sdk/types.js' -import { createLogger } from '@sim/logger' -import { toError } from '@sim/utils/errors' -import { generateId } from '@sim/utils/id' -import { authorizeWorkflowByWorkspacePermission } from '@sim/workflow-authz' -import { type NextRequest, NextResponse } from 'next/server' -import { mcpRequestBodySchema, mcpToolCallParamsSchema } from '@/lib/api/contracts/mcp' -import { validateOAuthAccessToken } from '@/lib/auth/oauth-token' -import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription' -import { generateWorkspaceContext } from '@/lib/copilot/chat/workspace-context' -import { ORCHESTRATION_TIMEOUT_MS, SIM_AGENT_API_URL } from '@/lib/copilot/constants' -import { createRequestId } from '@/lib/copilot/request/http' -import { runHeadlessCopilotLifecycle } from '@/lib/copilot/request/lifecycle/headless' -import { orchestrateSubagentStream } from '@/lib/copilot/request/subagent' -import { ensureHandlersRegistered, executeTool } from '@/lib/copilot/tool-executor' -import { ensureWorkspaceAccess } from '@/lib/copilot/tools/handlers/access' -import { prepareExecutionContext } from '@/lib/copilot/tools/handlers/context' -import { DIRECT_TOOL_DEFS, SUBAGENT_TOOL_DEFS } from '@/lib/copilot/tools/mcp/definitions' -import { env } from '@/lib/core/config/env' -import { RateLimiter } from '@/lib/core/rate-limiter' -import { getBaseUrl } from '@/lib/core/utils/urls' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { resolveWorkflowIdForUser } from '@/lib/workflows/utils' - -const logger = createLogger('CopilotMcpAPI') -const mcpRateLimiter = new RateLimiter() -const DEFAULT_COPILOT_MODEL = 'claude-opus-4-6' +import { + copilotMcpDeprecatedJsonRpcResponse, + copilotMcpDeprecatedResponse, +} from '@/lib/mcp/copilot-deprecated' export const dynamic = 'force-dynamic' -export const runtime = 'nodejs' -export const maxDuration = 3600 -interface CopilotKeyAuthResult { - success: boolean - userId?: string - error?: string -} +export const GET = withRouteHandler(async () => copilotMcpDeprecatedResponse()) -/** - * Validates a copilot API key by forwarding it to the Go copilot service's - * `/api/validate-key` endpoint. Returns the associated userId on success. - */ -async function authenticateCopilotApiKey(apiKey: string): Promise { - try { - const internalSecret = env.INTERNAL_API_SECRET - if (!internalSecret) { - logger.error('INTERNAL_API_SECRET not configured') - return { success: false, error: 'Server configuration error' } - } +export const POST = withRouteHandler(async () => copilotMcpDeprecatedJsonRpcResponse()) - const { fetchGo } = await import('@/lib/copilot/request/go/fetch') - const res = await fetchGo(`${SIM_AGENT_API_URL}/api/validate-key`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'x-api-key': internalSecret, - }, - body: JSON.stringify({ targetApiKey: apiKey }), - signal: AbortSignal.timeout(10_000), - spanName: 'sim → go /api/validate-key (mcp)', - operation: 'mcp_validate_key', - }) - - if (!res.ok) { - const body = await res.json().catch(() => null) - const upstream = (body as Record)?.message - const status = res.status - - if (status === 401 || status === 403) { - return { - success: false, - error: `Invalid Copilot API key. Generate a new key in Settings → Copilot and set it in the x-api-key header.`, - } - } - if (status === 402) { - return { - success: false, - error: `Usage limit exceeded for this Copilot API key. Upgrade your plan or wait for your quota to reset.`, - } - } - - return { - success: false, - error: String(upstream ?? 'Copilot API key validation failed'), - } - } - - const data = (await res.json()) as { ok?: boolean; userId?: string } - if (!data.ok || !data.userId) { - return { - success: false, - error: 'Invalid Copilot API key. Generate a new key in Settings → Copilot.', - } - } - - return { success: true, userId: data.userId } - } catch (error) { - logger.error('Copilot API key validation failed', { error }) - return { - success: false, - error: - 'Could not validate Copilot API key — the authentication service is temporarily unreachable. This is NOT a problem with the API key itself; please retry shortly.', - } - } -} - -/** - * MCP Server instructions that guide LLMs on how to use the Sim copilot tools. - * This is included in the initialize response to help external LLMs understand - * the workflow lifecycle and best practices. - */ -const MCP_SERVER_INSTRUCTIONS = ` -## Sim Workflow Copilot - -Sim is a workflow automation platform. Workflows are visual pipelines of connected blocks (Agent, Function, Condition, API, integrations, etc.). The Agent block is the core — an LLM with tools, memory, structured output, and knowledge bases. - -### Workflow Lifecycle (Happy Path) - -1. \`list_workspaces\` → know where to work -2. \`create_workflow(name, workspaceId)\` → get a workflowId -3. \`sim_workflow(request, workflowId)\` → plan and build in one pass -4. \`sim_test(request, workflowId)\` → verify it works -5. \`sim_deploy("deploy as api", workflowId)\` → make it accessible externally (optional) - -### Working with Existing Workflows - -When the user refers to a workflow by name or description ("the email one", "my Slack bot"): -1. Use \`sim_discovery\` to find it by functionality -2. Or use \`list_workflows\` and match by name -3. Then pass the workflowId to other tools - -### Organization - -- \`rename_workflow\` — rename a workflow -- \`move_workflow\` — move a workflow into a folder (or back to root by clearing the folder id) -- \`move_folder\` — nest a folder inside another (or move it back to root by clearing the parent id) -- \`create_folder(name, parentId)\` — create nested folder hierarchies - -### Key Rules - -- You can test workflows immediately after building — deployment is only needed for external access (API, chat, MCP). -- Tools that operate on a specific workflow such as \`sim_workflow\`, \`sim_test\`, \`sim_deploy\`, and workflow-scoped \`sim_info\` requests require \`workflowId\`. -- If the user reports errors, route through \`sim_workflow\` and ask it to reproduce, inspect logs, and fix the issue end to end. -- Variable syntax: \`\` for block outputs, \`{{ENV_VAR}}\` for env vars. -` - -type HeaderMap = Record - -function createError(id: RequestId, code: ErrorCode | number, message: string): JSONRPCError { - return { - jsonrpc: '2.0', - id, - error: { code, message }, - } -} - -function readHeader(headers: HeaderMap | undefined, name: string): string | undefined { - if (!headers) return undefined - const value = headers[name.toLowerCase()] - if (Array.isArray(value)) { - return value[0] - } - return value -} - -function buildMcpServer(abortSignal?: AbortSignal): Server { - const server = new Server( - { - name: 'sim-copilot', - version: '1.0.0', - }, - { - capabilities: { tools: {} }, - instructions: MCP_SERVER_INSTRUCTIONS, - } - ) - - server.setRequestHandler(ListToolsRequestSchema, async () => { - const directTools = DIRECT_TOOL_DEFS.map((tool) => ({ - name: tool.name, - description: tool.description, - inputSchema: tool.inputSchema, - ...(tool.annotations && { annotations: tool.annotations }), - })) - - const subagentTools = SUBAGENT_TOOL_DEFS.map((tool) => ({ - name: tool.name, - description: tool.description, - inputSchema: tool.inputSchema, - ...(tool.annotations && { annotations: tool.annotations }), - })) - - const result: ListToolsResult = { - tools: [...directTools, ...subagentTools], - } - - return result - }) - - server.setRequestHandler(CallToolRequestSchema, async (request, extra) => { - const headers = (extra.requestInfo?.headers || {}) as HeaderMap - const apiKeyHeader = readHeader(headers, 'x-api-key') - const authorizationHeader = readHeader(headers, 'authorization') - - let authResult: CopilotKeyAuthResult = { success: false } - - if (authorizationHeader?.startsWith('Bearer ')) { - const token = authorizationHeader.slice(7) - const oauthResult = await validateOAuthAccessToken(token) - if (oauthResult.success && oauthResult.userId) { - if (!oauthResult.scopes?.includes('mcp:tools')) { - return { - content: [ - { - type: 'text' as const, - text: 'AUTHENTICATION ERROR: OAuth token is missing the required "mcp:tools" scope. Re-authorize with the correct scopes.', - }, - ], - isError: true, - } - } - authResult = { success: true, userId: oauthResult.userId } - } else { - return { - content: [ - { - type: 'text' as const, - text: `AUTHENTICATION ERROR: ${oauthResult.error ?? 'Invalid OAuth access token'} Do NOT retry — re-authorize via OAuth.`, - }, - ], - isError: true, - } - } - } else if (apiKeyHeader) { - authResult = await authenticateCopilotApiKey(apiKeyHeader) - } - - if (!authResult.success || !authResult.userId) { - const errorMsg = apiKeyHeader - ? `AUTHENTICATION ERROR: ${authResult.error} Do NOT retry — this will fail until the user fixes their Copilot API key.` - : 'AUTHENTICATION ERROR: No authentication provided. Provide a Bearer token (OAuth 2.1) or an x-api-key header. Generate a Copilot API key in Settings → Copilot.' - logger.warn('MCP copilot auth failed', { method: request.method }) - return { - content: [ - { - type: 'text' as const, - text: errorMsg, - }, - ], - isError: true, - } - } - - const rateLimitResult = await mcpRateLimiter.checkRateLimitWithSubscription( - authResult.userId, - await getHighestPrioritySubscription(authResult.userId), - 'api-endpoint', - false - ) - - if (!rateLimitResult.allowed) { - return { - content: [ - { - type: 'text' as const, - text: `RATE LIMIT: Too many requests. Please wait and retry after ${rateLimitResult.resetAt.toISOString()}.`, - }, - ], - isError: true, - } - } - - const paramsValidation = mcpToolCallParamsSchema.safeParse(request.params) - if (!paramsValidation.success) { - throw new McpError(ErrorCode.InvalidParams, 'Tool name required') - } - const params = paramsValidation.data - - const result = await handleToolsCall( - { - name: params.name, - arguments: params.arguments, - }, - authResult.userId, - abortSignal - ) - - return result - }) - - return server -} - -async function handleMcpRequestWithSdk( - request: NextRequest, - parsedBody: unknown -): Promise { - const server = buildMcpServer(request.signal) - const transport = new WebStandardStreamableHTTPServerTransport({ - sessionIdGenerator: undefined, - enableJsonResponse: true, - }) - - await server.connect(transport) - - try { - return await transport.handleRequest(request, { parsedBody }) - } finally { - await server.close().catch(() => {}) - await transport.close().catch(() => {}) - } -} - -export const GET = withRouteHandler(async () => { - // Return 405 to signal that server-initiated SSE notifications are not - // supported. Without this, clients like mcp-remote will repeatedly - // reconnect trying to open an SSE stream, flooding the logs with GETs. - return new NextResponse(null, { status: 405 }) -}) - -export const POST = withRouteHandler(async (request: NextRequest) => { - const hasAuth = request.headers.has('authorization') || request.headers.has('x-api-key') - - if (!hasAuth) { - const origin = getBaseUrl().replace(/\/$/, '') - const resourceMetadataUrl = `${origin}/.well-known/oauth-protected-resource/api/mcp/copilot` - return new NextResponse(JSON.stringify({ error: 'unauthorized' }), { - status: 401, - headers: { - 'WWW-Authenticate': `Bearer resource_metadata="${resourceMetadataUrl}", scope="mcp:tools"`, - 'Content-Type': 'application/json', - }, - }) - } - - try { - let parsedBody: unknown - - try { - parsedBody = await request.json() - } catch { - return NextResponse.json(createError(0, ErrorCode.ParseError, 'Invalid JSON body'), { - status: 400, - }) - } - - const bodyValidation = mcpRequestBodySchema.safeParse(parsedBody) - if (!bodyValidation.success) { - return NextResponse.json( - createError(0, ErrorCode.InvalidRequest, 'Invalid JSON-RPC message'), - { - status: 400, - } - ) - } - - return await handleMcpRequestWithSdk(request, bodyValidation.data) - } catch (error) { - if (request.signal.aborted || (error as Error)?.name === 'AbortError') { - return NextResponse.json( - createError(0, ErrorCode.ConnectionClosed, 'Client cancelled request'), - { status: 499 } - ) - } - - logger.error('Error handling MCP request', { error }) - return NextResponse.json(createError(0, ErrorCode.InternalError, 'Internal error'), { - status: 500, - }) - } -}) - -export const DELETE = withRouteHandler(async (request: NextRequest) => { - void request - return NextResponse.json(createError(0, -32000, 'Method not allowed.'), { status: 405 }) -}) - -async function handleToolsCall( - params: { name: string; arguments?: Record }, - userId: string, - abortSignal?: AbortSignal -): Promise { - const args = params.arguments || {} - - const directTool = DIRECT_TOOL_DEFS.find((tool) => tool.name === params.name) - if (directTool) { - return handleDirectToolCall(directTool, args, userId) - } - - const subagentTool = SUBAGENT_TOOL_DEFS.find((tool) => tool.name === params.name) - if (subagentTool) { - return handleSubagentToolCall(subagentTool, args, userId, abortSignal) - } - - throw new McpError(ErrorCode.MethodNotFound, `Tool not found: ${params.name}`) -} - -async function handleDirectToolCall( - toolDef: (typeof DIRECT_TOOL_DEFS)[number], - args: Record, - userId: string -): Promise { - try { - const rawWorkflowId = (args.workflowId as string) || '' - let resolvedWorkspaceId: string | undefined - if (rawWorkflowId) { - const authorization = await authorizeWorkflowByWorkspacePermission({ - workflowId: rawWorkflowId, - userId, - action: 'read', - }) - if (!authorization.allowed) { - return { - content: [ - { - type: 'text', - text: JSON.stringify( - { success: false, error: 'Workflow not found or access denied' }, - null, - 2 - ), - }, - ], - isError: true, - } - } - resolvedWorkspaceId = authorization.workflow?.workspaceId || undefined - } - const execContext = await prepareExecutionContext( - userId, - rawWorkflowId, - (args.chatId as string) || undefined, - { workspaceId: resolvedWorkspaceId } - ) - - const toolCall = { - id: generateId(), - name: toolDef.toolId, - status: 'pending' as const, - params: args as Record, - startTime: Date.now(), - } - - ensureHandlersRegistered() - const result = await executeTool(toolCall.name, toolCall.params || {}, execContext) - - return { - content: [ - { - type: 'text', - text: JSON.stringify(result.output ?? result, null, 2), - }, - ], - isError: !result.success, - } - } catch (error) { - logger.error('Direct tool execution failed', { tool: toolDef.name, error }) - return { - content: [ - { - type: 'text', - text: `Tool execution failed: ${toError(error).message}`, - }, - ], - isError: true, - } - } -} - -/** - * Build mode uses the main /api/mcp orchestrator instead of /api/subagent/workflow. - * The main agent still delegates workflow work to the workflow subagent inside Go; - * this helper simply uses the full headless lifecycle so build requests behave like - * the primary MCP chat flow. - */ -async function handleBuildToolCall( - args: Record, - userId: string, - abortSignal?: AbortSignal -): Promise { - try { - const requestText = (args.request as string) || JSON.stringify(args) - const workflowId = args.workflowId as string | undefined - let resolvedWorkflowName: string | undefined - let resolvedWorkspaceId: string | undefined - - const resolved = workflowId - ? await (async () => { - const authorization = await authorizeWorkflowByWorkspacePermission({ - workflowId, - userId, - action: 'read', - }) - resolvedWorkflowName = authorization.workflow?.name || undefined - resolvedWorkspaceId = authorization.workflow?.workspaceId || undefined - return authorization.allowed - ? { - status: 'resolved' as const, - workflowId, - workflowName: resolvedWorkflowName, - } - : { - status: 'not_found' as const, - message: 'workflowId is required for build. Call create_workflow first.', - } - })() - : await resolveWorkflowIdForUser(userId) - - if (resolved.status === 'resolved') { - resolvedWorkflowName ||= resolved.workflowName - } - - if (!resolved || resolved.status !== 'resolved') { - return { - content: [ - { - type: 'text', - text: JSON.stringify( - { - success: false, - error: - resolved?.message ?? - 'workflowId is required for build. Call create_workflow first.', - }, - null, - 2 - ), - }, - ], - isError: true, - } - } - - const chatId = generateId() - const executionContext = await prepareExecutionContext(userId, resolved.workflowId, chatId, { - workspaceId: resolvedWorkspaceId, - }) - resolvedWorkspaceId = executionContext.workspaceId - let workspaceContext: string | undefined - if (resolvedWorkspaceId) { - try { - workspaceContext = await generateWorkspaceContext(resolvedWorkspaceId, userId) - } catch (error) { - logger.warn('Failed to generate workspace context for build tool call', { - workflowId: resolved.workflowId, - workspaceId: resolvedWorkspaceId, - error: toError(error).message, - }) - } - } - - const requestPayload = { - message: requestText, - workflowId: resolved.workflowId, - ...(resolvedWorkflowName ? { workflowName: resolvedWorkflowName } : {}), - ...(resolvedWorkspaceId ? { workspaceId: resolvedWorkspaceId } : {}), - ...(workspaceContext ? { workspaceContext } : {}), - userId, - model: DEFAULT_COPILOT_MODEL, - mode: 'agent', - commands: ['fast'], - messageId: generateId(), - chatId, - } - - const result = await runHeadlessCopilotLifecycle(requestPayload, { - userId, - workflowId: resolved.workflowId, - workspaceId: resolvedWorkspaceId, - chatId, - goRoute: '/api/mcp', - executionContext, - autoExecuteTools: true, - timeout: ORCHESTRATION_TIMEOUT_MS, - interactive: false, - abortSignal, - }) - - const responseData = { - success: result.success, - content: result.content, - toolCalls: result.toolCalls, - error: result.error, - } - - return { - content: [{ type: 'text', text: JSON.stringify(responseData, null, 2) }], - isError: !result.success, - } - } catch (error) { - logger.error('Build tool call failed', { error }) - return { - content: [ - { - type: 'text', - text: `Build failed: ${toError(error).message}`, - }, - ], - isError: true, - } - } -} - -async function handleSubagentToolCall( - toolDef: (typeof SUBAGENT_TOOL_DEFS)[number], - args: Record, - userId: string, - abortSignal?: AbortSignal -): Promise { - if (toolDef.agentId === 'workflow') { - return handleBuildToolCall(args, userId, abortSignal) - } - - try { - const requestText = - (args.request as string) || - (args.message as string) || - (args.error as string) || - JSON.stringify(args) - const simRequestId = createRequestId() - - const context = (args.context as Record) || {} - if (args.plan && !context.plan) { - context.plan = args.plan - } - - // Authorize user-supplied workflowId / workspaceId before forwarding downstream - const rawWorkflowId = args.workflowId as string | undefined - const rawWorkspaceId = args.workspaceId as string | undefined - let resolvedWorkflowId: string | undefined - let resolvedWorkspaceId: string | undefined - - if (rawWorkflowId) { - const authorization = await authorizeWorkflowByWorkspacePermission({ - workflowId: rawWorkflowId, - userId, - action: 'read', - }) - if (!authorization.allowed) { - return { - content: [ - { - type: 'text', - text: JSON.stringify( - { success: false, error: 'Workflow not found or access denied' }, - null, - 2 - ), - }, - ], - isError: true, - } - } - resolvedWorkflowId = rawWorkflowId - resolvedWorkspaceId = authorization.workflow?.workspaceId || undefined - } else if (rawWorkspaceId) { - await ensureWorkspaceAccess(rawWorkspaceId, userId, 'read') - resolvedWorkspaceId = rawWorkspaceId - } - - const result = await orchestrateSubagentStream( - toolDef.agentId, - { - message: requestText, - workflowId: resolvedWorkflowId, - workspaceId: resolvedWorkspaceId, - context, - model: DEFAULT_COPILOT_MODEL, - headless: true, - source: 'mcp', - }, - { - userId, - workflowId: resolvedWorkflowId, - workspaceId: resolvedWorkspaceId, - simRequestId, - abortSignal, - } - ) - - let responseData: unknown - - if (result.structuredResult) { - responseData = { - success: result.structuredResult.success ?? result.success, - type: result.structuredResult.type, - summary: result.structuredResult.summary, - data: result.structuredResult.data, - } - } else if (result.error) { - responseData = { - success: false, - error: result.error, - errors: result.errors, - } - } else { - responseData = { - success: result.success, - content: result.content, - } - } - - return { - content: [ - { - type: 'text', - text: JSON.stringify(responseData, null, 2), - }, - ], - isError: !result.success, - } - } catch (error) { - logger.error('Subagent tool call failed', { - tool: toolDef.name, - agentId: toolDef.agentId, - error, - }) - - return { - content: [ - { - type: 'text', - text: `Subagent call failed: ${toError(error).message}`, - }, - ], - isError: true, - } - } -} +export const DELETE = withRouteHandler(async () => copilotMcpDeprecatedJsonRpcResponse()) diff --git a/apps/sim/app/api/v1/copilot/chat/route.test.ts b/apps/sim/app/api/v1/copilot/chat/route.test.ts new file mode 100644 index 0000000000..fef0a7eec8 --- /dev/null +++ b/apps/sim/app/api/v1/copilot/chat/route.test.ts @@ -0,0 +1,26 @@ +/** + * Tests for the deprecated v1 copilot chat API route + * + * @vitest-environment node + */ +import { NextRequest } from 'next/server' +import { describe, expect, it } from 'vitest' +import { POST } from '@/app/api/v1/copilot/chat/route' + +const URL = 'http://localhost:3000/api/v1/copilot/chat' + +describe('Deprecated v1 copilot chat route', () => { + it('POST returns 410 with a success:false error body', async () => { + const request = new NextRequest(URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'x-api-key': 'sk-test' }, + body: JSON.stringify({ message: 'hello' }), + }) + const response = await POST(request) + expect(response.status).toBe(410) + + const body = (await response.json()) as { success?: boolean; error?: string } + expect(body.success).toBe(false) + expect(body.error).toContain('deprecated') + }) +}) diff --git a/apps/sim/app/api/v1/copilot/chat/route.ts b/apps/sim/app/api/v1/copilot/chat/route.ts index 6eaa1424b7..ac3759d96f 100644 --- a/apps/sim/app/api/v1/copilot/chat/route.ts +++ b/apps/sim/app/api/v1/copilot/chat/route.ts @@ -1,151 +1,18 @@ -import { createLogger } from '@sim/logger' -import { toError } from '@sim/utils/errors' -import { generateId } from '@sim/utils/id' -import { type NextRequest, NextResponse } from 'next/server' -import { v1CopilotChatContract } from '@/lib/api/contracts/v1/copilot' -import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' -import { runHeadlessCopilotLifecycle } from '@/lib/copilot/request/lifecycle/headless' +import { NextResponse } from 'next/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { getWorkflowById, resolveWorkflowIdForUser } from '@/lib/workflows/utils' -import { authenticateRequest } from '@/app/api/v1/middleware' - -export const maxDuration = 3600 - -const logger = createLogger('CopilotHeadlessAPI') -const DEFAULT_COPILOT_MODEL = 'claude-opus-4-6' /** * POST /api/v1/copilot/chat - * Headless copilot endpoint for server-side orchestration. * - * workflowId is optional - if not provided: - * - If workflowName is provided, finds that workflow - * - If exactly one workflow is available, uses that workflow as context - * - Otherwise requires workflowId or workflowName to disambiguate + * Deprecated: the v1 headless copilot chat API has been removed. The endpoint + * returns 410 Gone for all callers. */ -export const POST = withRouteHandler(async (req: NextRequest) => { - let messageId: string | undefined - const authorized = await authenticateRequest(req, 'copilot-chat') - if (authorized instanceof NextResponse) { - return authorized - } - const { userId, rateLimit } = authorized - const auth = { - authenticated: true as const, - userId, - keyType: rateLimit.keyType, - workspaceId: rateLimit.workspaceId, - } - - try { - const parsedRequest = await parseRequest( - v1CopilotChatContract, - req, - {}, - { - validationErrorResponse: (error) => - NextResponse.json( - { - success: false, - error: getValidationErrorMessage(error, 'Invalid request'), - details: error.issues, - }, - { status: 400 } - ), - invalidJsonResponse: () => - NextResponse.json({ success: false, error: 'Invalid request' }, { status: 400 }), - } - ) - if (!parsedRequest.success) return parsedRequest.response - - const parsed = parsedRequest.data.body - const selectedModel = parsed.model || DEFAULT_COPILOT_MODEL - - // Resolve workflow ID - const resolved = await resolveWorkflowIdForUser( - auth.userId, - parsed.workflowId, - parsed.workflowName, - auth.keyType === 'workspace' ? auth.workspaceId : undefined - ) - if (resolved.status !== 'resolved') { - return NextResponse.json( - { - success: false, - error: resolved.message, - }, - { status: 400 } - ) - } - - if (auth.keyType === 'workspace' && auth.workspaceId) { - const workflow = await getWorkflowById(resolved.workflowId) - if (!workflow?.workspaceId || workflow.workspaceId !== auth.workspaceId) { - return NextResponse.json( - { success: false, error: 'API key is not authorized for this workspace' }, - { status: 403 } - ) - } - } - - // Transform mode to transport mode (same as client API) - // build and agent both map to 'agent' on the backend - const effectiveMode = parsed.mode === 'agent' ? 'build' : parsed.mode - const transportMode = effectiveMode === 'build' ? 'agent' : effectiveMode - - // Always generate a chatId - required for artifacts system to work with subagents - const chatId = parsed.chatId || generateId() - - messageId = generateId() - logger.info( - messageId - ? `Received headless copilot chat start request [messageId:${messageId}]` - : 'Received headless copilot chat start request', - { - workflowId: resolved.workflowId, - workflowName: parsed.workflowName, - chatId, - mode: transportMode, - autoExecuteTools: parsed.autoExecuteTools, - timeout: parsed.timeout, - } - ) - const requestPayload = { - message: parsed.message, - workflowId: resolved.workflowId, - userId: auth.userId, - model: selectedModel, - mode: transportMode, - messageId, - chatId, - } - - const result = await runHeadlessCopilotLifecycle(requestPayload, { - userId: auth.userId, - workflowId: resolved.workflowId, - chatId, - goRoute: '/api/mcp', - autoExecuteTools: parsed.autoExecuteTools, - timeout: parsed.timeout, - interactive: false, - }) - - return NextResponse.json({ - success: result.success, - content: result.content, - toolCalls: result.toolCalls, - chatId: result.chatId || chatId, - error: result.error, - }) - } catch (error) { - logger.error( - messageId - ? `Headless copilot request failed [messageId:${messageId}]` - : 'Headless copilot request failed', - { - error: toError(error).message, - } - ) - return NextResponse.json({ success: false, error: 'Internal server error' }, { status: 500 }) - } -}) +export const POST = withRouteHandler(async () => + NextResponse.json( + { + success: false, + error: 'The v1 copilot chat API has been deprecated and is no longer available.', + }, + { status: 410, headers: { 'Cache-Control': 'no-store' } } + ) +) diff --git a/apps/sim/lib/mcp/copilot-deprecated.ts b/apps/sim/lib/mcp/copilot-deprecated.ts new file mode 100644 index 0000000000..a3be7e7ebe --- /dev/null +++ b/apps/sim/lib/mcp/copilot-deprecated.ts @@ -0,0 +1,35 @@ +import { NextResponse } from 'next/server' + +const DEPRECATION_MESSAGE = 'Copilot MCP has been deprecated and is no longer available.' + +/** + * Standard 410 response for the deprecated Copilot MCP surface. Used by the + * copilot MCP resource route and its copilot-specific OAuth discovery routes. + */ +export function copilotMcpDeprecatedResponse(): NextResponse { + return NextResponse.json( + { error: 'gone', message: DEPRECATION_MESSAGE }, + { + status: 410, + headers: { 'Cache-Control': 'no-store' }, + } + ) +} + +/** + * JSON-RPC flavored 410 response for the deprecated Copilot MCP `POST` endpoint, + * so MCP clients surface a clean error envelope instead of an opaque body. + */ +export function copilotMcpDeprecatedJsonRpcResponse(): NextResponse { + return NextResponse.json( + { + jsonrpc: '2.0', + id: null, + error: { code: -32000, message: DEPRECATION_MESSAGE }, + }, + { + status: 410, + headers: { 'Cache-Control': 'no-store' }, + } + ) +} diff --git a/packages/db/.env.swp b/packages/db/.env.swp new file mode 100644 index 0000000000000000000000000000000000000000..27153b0737ce866350e9e2be1f2d286f244ca2e2 GIT binary patch literal 12288 zcmeI&%}T>S5Ww+i@1p1n)Oc>UUmgqyrc_X{AZoozzGyB*leuU1`dowr@S%y(?Z|A{WFTB*x z!!o5VCbm1$I%=P%x;^Z~+O^}lGua8%5|c$aIO;DjqI79DbGk>^d3w_=q4|Eu$VIe#tJ2m}y7009ILKmY**5I_I{ z1Q7T?fw6UEjjYS#o9V^bp^@k;pQV0w8=Fd16SI0b`o5DN6glSpaL_NW+h_Tm7ivSk E0QbB{H2?qr literal 0 HcmV?d00001 diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts index 5b321a818d..4fb99356ee 100644 --- a/scripts/check-api-validation-contracts.ts +++ b/scripts/check-api-validation-contracts.ts @@ -83,6 +83,14 @@ const INDIRECT_ZOD_ROUTES = new Set([ // that closes the popup, so the JSON-mode contract framework doesn't fit. // Validation is enforced via state lookup + session-vs-row userId match. 'apps/sim/app/api/mcp/oauth/callback/route.ts', + // Deprecated Copilot MCP surface: these routes are gated to always return + // 410 Gone and consume no client-supplied input. + 'apps/sim/app/api/mcp/copilot/route.ts', + 'apps/sim/app/api/mcp/copilot/.well-known/oauth-authorization-server/route.ts', + 'apps/sim/app/api/mcp/copilot/.well-known/oauth-protected-resource/route.ts', + // Deprecated v1 headless copilot chat API: gated to always return 410 Gone + // and consumes no client-supplied input. + 'apps/sim/app/api/v1/copilot/chat/route.ts', ]) /** @@ -110,7 +118,6 @@ const RAW_JSON_BASELINE_ROUTES = new Set([ 'apps/sim/app/api/invitations/[id]/route.ts', 'apps/sim/app/api/knowledge/[id]/documents/route.ts', 'apps/sim/app/api/knowledge/[id]/documents/[documentId]/chunks/route.ts', - 'apps/sim/app/api/mcp/copilot/route.ts', 'apps/sim/app/api/mcp/serve/[serverId]/route.ts', 'apps/sim/app/api/mcp/servers/route.ts', 'apps/sim/app/api/mcp/servers/[id]/route.ts', From 704362dc5e0e315df91838c7a2dda4bd3e8bce87 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Fri, 29 May 2026 15:36:59 -0700 Subject: [PATCH 04/29] feat(integrations): hosted API keys for Findymail, Prospeo, and Wiza (#4777) * feat(integrations): hosted API keys for Findymail, Prospeo, and Wiza Add hosted-key support across all credit-consuming Findymail, Prospeo, and Wiza operations so Sim provides the key when a workspace has not brought its own. Register the three BYOK providers, consolidate Wiza's two-step reveal into a single polling wiza_individual_reveal op, and hide the API key field on hosted Sim for hosted operations. * fix(integrations): harden Wiza reveal polling, soften enrichment getCost guards Address Greptile + Cursor Bugbot review on #4777: return explicit failures from the Wiza individual_reveal poller instead of throwing (thrown errors were swallowed into a false queued success), short-circuit when the initial reveal is already terminal, tolerate transient 5xx/429 during polling, and return 0 (not throw) from Findymail getCost when the contacts/employees array is absent. * chore(integrations): biome formatting after wiza merge resolution * fix(wiza): type isTerminalReveal param structurally for next build typecheck * feat(enrichments): add Findymail, Prospeo, Wiza to work-email waterfall * feat(enrichments): add Wiza + Prospeo phone reveal to phone-number waterfall * feat(enrichments): opportunistic identifiers + LinkedIn URL input across work-email & phone cascades --- .../settings/components/byok/byok.tsx | 24 ++ apps/sim/blocks/blocks/findymail.ts | 14 +- apps/sim/blocks/blocks/prospeo.ts | 21 +- apps/sim/blocks/blocks/wiza.ts | 137 ++++---- .../phone-number/phone-number.test.ts | 78 +++++ .../enrichments/phone-number/phone-number.ts | 75 +++- .../enrichments/work-email/work-email.test.ts | 86 +++++ apps/sim/enrichments/work-email/work-email.ts | 90 ++++- apps/sim/lib/api/contracts/byok-keys.ts | 3 + apps/sim/tools/enrichment-hosting.test.ts | 277 +++++++++++++++ .../findymail/find_email_from_linkedin.ts | 6 + .../tools/findymail/find_email_from_name.ts | 6 + .../tools/findymail/find_emails_by_domain.ts | 10 + apps/sim/tools/findymail/find_employees.ts | 10 + apps/sim/tools/findymail/find_phone.ts | 6 + apps/sim/tools/findymail/get_company.ts | 6 + apps/sim/tools/findymail/hosting.ts | 42 +++ .../tools/findymail/lookup_technologies.ts | 6 + .../tools/findymail/reverse_email_lookup.ts | 8 + .../tools/findymail/search_technologies.ts | 6 + apps/sim/tools/findymail/verify_email.ts | 6 + apps/sim/tools/prospeo/bulk_enrich_company.ts | 9 + apps/sim/tools/prospeo/bulk_enrich_person.ts | 9 + apps/sim/tools/prospeo/enrich_company.ts | 7 + apps/sim/tools/prospeo/enrich_person.ts | 11 + apps/sim/tools/prospeo/hosting.ts | 43 +++ apps/sim/tools/prospeo/search_company.ts | 8 + apps/sim/tools/prospeo/search_person.ts | 8 + apps/sim/tools/registry.ts | 6 +- apps/sim/tools/types.ts | 5 +- apps/sim/tools/wiza/company_enrichment.ts | 6 + apps/sim/tools/wiza/get_individual_reveal.ts | 176 ---------- apps/sim/tools/wiza/hosting.ts | 42 +++ apps/sim/tools/wiza/index.ts | 3 +- apps/sim/tools/wiza/individual_reveal.ts | 330 ++++++++++++++++++ apps/sim/tools/wiza/prospect_search.ts | 7 + .../sim/tools/wiza/start_individual_reveal.ts | 151 -------- apps/sim/tools/wiza/types.ts | 23 +- 38 files changed, 1322 insertions(+), 439 deletions(-) create mode 100644 apps/sim/enrichments/phone-number/phone-number.test.ts create mode 100644 apps/sim/enrichments/work-email/work-email.test.ts create mode 100644 apps/sim/tools/enrichment-hosting.test.ts create mode 100644 apps/sim/tools/findymail/hosting.ts create mode 100644 apps/sim/tools/prospeo/hosting.ts delete mode 100644 apps/sim/tools/wiza/get_individual_reveal.ts create mode 100644 apps/sim/tools/wiza/hosting.ts create mode 100644 apps/sim/tools/wiza/individual_reveal.ts delete mode 100644 apps/sim/tools/wiza/start_individual_reveal.ts diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok.tsx index 4aaf902ffa..998f1c5dcf 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok.tsx @@ -19,6 +19,7 @@ import { AnthropicIcon, BrandfetchIcon, ExaAIIcon, + FindymailIcon, FirecrawlIcon, FireworksIcon, GeminiIcon, @@ -32,7 +33,9 @@ import { ParallelIcon, PeopleDataLabsIcon, PerplexityIcon, + ProspeoIcon, SerperIcon, + WizaIcon, } from '@/components/icons' import { Input } from '@/components/ui' import { BYOKKeySkeleton } from '@/app/workspace/[workspaceId]/settings/components/byok/byok-skeleton' @@ -172,6 +175,27 @@ const PROVIDERS: { description: 'Person and company enrichment, search, and identity', placeholder: 'Enter your People Data Labs API key', }, + { + id: 'findymail', + name: 'Findymail', + icon: FindymailIcon, + description: 'Email finder, verification, and phone lookup', + placeholder: 'Enter your Findymail API key', + }, + { + id: 'prospeo', + name: 'Prospeo', + icon: ProspeoIcon, + description: 'Person and company enrichment and search', + placeholder: 'Enter your Prospeo API key', + }, + { + id: 'wiza', + name: 'Wiza', + icon: WizaIcon, + description: 'Prospect search, individual reveal, and company enrichment', + placeholder: 'Enter your Wiza API key', + }, ] export function BYOK() { diff --git a/apps/sim/blocks/blocks/findymail.ts b/apps/sim/blocks/blocks/findymail.ts index 4f8bea4911..a112cf5ee8 100644 --- a/apps/sim/blocks/blocks/findymail.ts +++ b/apps/sim/blocks/blocks/findymail.ts @@ -214,7 +214,7 @@ export const FindymailBlock: BlockConfig = { placeholder: 'e.g. React, TypeScript, Node.js', }, }, - // API Key + // API Key — hidden on hosted Sim for operations with hosted-key support { id: 'apiKey', title: 'API Key', @@ -222,6 +222,18 @@ export const FindymailBlock: BlockConfig = { required: true, placeholder: 'Enter your Findymail API key', password: true, + hideWhenHosted: true, + condition: { field: 'operation', value: 'findymail_get_credits', not: true }, + }, + // API Key — always required for the credit-balance lookup (no hosted key) + { + id: 'apiKey', + title: 'API Key', + type: 'short-input', + required: true, + placeholder: 'Enter your Findymail API key', + password: true, + condition: { field: 'operation', value: 'findymail_get_credits' }, }, ], tools: { diff --git a/apps/sim/blocks/blocks/prospeo.ts b/apps/sim/blocks/blocks/prospeo.ts index 2980efdb96..b28fc3417a 100644 --- a/apps/sim/blocks/blocks/prospeo.ts +++ b/apps/sim/blocks/blocks/prospeo.ts @@ -302,7 +302,7 @@ export const ProspeoBlock: BlockConfig = { condition: { field: 'operation', value: 'prospeo_search_suggestions' }, }, - // API Key (always last) + // API Key — hidden on hosted Sim for operations with hosted-key support { id: 'apiKey', title: 'API Key', @@ -310,6 +310,25 @@ export const ProspeoBlock: BlockConfig = { required: true, placeholder: 'Enter your Prospeo API key', password: true, + hideWhenHosted: true, + condition: { + field: 'operation', + value: ['prospeo_search_suggestions', 'prospeo_account_information'], + not: true, + }, + }, + // API Key — always required for the free account/suggestion lookups (no hosted key) + { + id: 'apiKey', + title: 'API Key', + type: 'short-input', + required: true, + placeholder: 'Enter your Prospeo API key', + password: true, + condition: { + field: 'operation', + value: ['prospeo_search_suggestions', 'prospeo_account_information'], + }, }, ], tools: { diff --git a/apps/sim/blocks/blocks/wiza.ts b/apps/sim/blocks/blocks/wiza.ts index 91a1acdff8..fe648c0f7a 100644 --- a/apps/sim/blocks/blocks/wiza.ts +++ b/apps/sim/blocks/blocks/wiza.ts @@ -24,8 +24,7 @@ export const WizaBlock: BlockConfig = { options: [ { label: 'Prospect Search', id: 'prospect_search' }, { label: 'Company Enrichment', id: 'company_enrichment' }, - { label: 'Start Individual Reveal', id: 'start_individual_reveal' }, - { label: 'Get Individual Reveal', id: 'get_individual_reveal' }, + { label: 'Individual Reveal', id: 'individual_reveal' }, { label: 'Get Credits', id: 'get_credits' }, ], value: () => 'prospect_search', @@ -37,6 +36,17 @@ export const WizaBlock: BlockConfig = { placeholder: 'Enter your Wiza API key', password: true, required: true, + hideWhenHosted: true, + condition: { field: 'operation', value: 'get_credits', not: true }, + }, + { + id: 'apiKey', + title: 'Wiza API Key', + type: 'short-input', + placeholder: 'Enter your Wiza API key', + password: true, + required: true, + condition: { field: 'operation', value: 'get_credits' }, }, // Prospect Search @@ -287,7 +297,7 @@ Return ONLY the JSON object - no explanations, no extra text.`, mode: 'advanced', }, - // Start Individual Reveal + // Individual Reveal { id: 'enrichment_level', title: 'Enrichment Level', @@ -298,85 +308,66 @@ Return ONLY the JSON object - no explanations, no extra text.`, { label: 'Phone', id: 'phone' }, { label: 'Full', id: 'full' }, ], - value: () => 'partial', - condition: { field: 'operation', value: 'start_individual_reveal' }, - required: { field: 'operation', value: 'start_individual_reveal' }, + value: () => 'full', + condition: { field: 'operation', value: 'individual_reveal' }, + required: { field: 'operation', value: 'individual_reveal' }, }, { id: 'profile_url', title: 'LinkedIn Profile URL', type: 'short-input', placeholder: 'https://linkedin.com/in/johndoe', - condition: { field: 'operation', value: 'start_individual_reveal' }, + condition: { field: 'operation', value: 'individual_reveal' }, }, { id: 'full_name', title: 'Full Name', type: 'short-input', placeholder: 'John Doe', - condition: { field: 'operation', value: 'start_individual_reveal' }, + condition: { field: 'operation', value: 'individual_reveal' }, }, { id: 'company', title: 'Company', type: 'short-input', placeholder: 'Wiza', - condition: { field: 'operation', value: 'start_individual_reveal' }, + condition: { field: 'operation', value: 'individual_reveal' }, }, { id: 'domain', title: 'Company Domain', type: 'short-input', placeholder: 'wiza.co', - condition: { field: 'operation', value: 'start_individual_reveal' }, + condition: { field: 'operation', value: 'individual_reveal' }, }, { id: 'email', title: 'Email', type: 'short-input', placeholder: 'john@wiza.co', - condition: { field: 'operation', value: 'start_individual_reveal' }, + condition: { field: 'operation', value: 'individual_reveal' }, }, { id: 'accept_work', title: 'Accept Work Emails', type: 'switch', - condition: { field: 'operation', value: 'start_individual_reveal' }, + condition: { field: 'operation', value: 'individual_reveal' }, mode: 'advanced', }, { id: 'accept_personal', title: 'Accept Personal Emails', type: 'switch', - condition: { field: 'operation', value: 'start_individual_reveal' }, + condition: { field: 'operation', value: 'individual_reveal' }, mode: 'advanced', }, - { - id: 'callback_url', - title: 'Callback URL', - type: 'short-input', - placeholder: 'https://example.com/wiza-callback', - condition: { field: 'operation', value: 'start_individual_reveal' }, - mode: 'advanced', - }, - - // Get Individual Reveal - { - id: 'id', - title: 'Reveal ID', - type: 'short-input', - placeholder: 'Reveal ID returned from Start Individual Reveal', - condition: { field: 'operation', value: 'get_individual_reveal' }, - required: { field: 'operation', value: 'get_individual_reveal' }, - }, ], tools: { access: [ 'wiza_prospect_search', 'wiza_company_enrichment', - 'wiza_start_individual_reveal', - 'wiza_get_individual_reveal', + 'wiza_individual_reveal', 'wiza_get_credits', ], config: { @@ -386,10 +377,8 @@ Return ONLY the JSON object - no explanations, no extra text.`, return 'wiza_prospect_search' case 'company_enrichment': return 'wiza_company_enrichment' - case 'start_individual_reveal': - return 'wiza_start_individual_reveal' - case 'get_individual_reveal': - return 'wiza_get_individual_reveal' + case 'individual_reveal': + return 'wiza_individual_reveal' case 'get_credits': return 'wiza_get_credits' default: @@ -479,8 +468,6 @@ Return ONLY the JSON object - no explanations, no extra text.`, email: { type: 'string', description: 'Email address' }, accept_work: { type: 'boolean', description: 'Whether to accept work emails' }, accept_personal: { type: 'boolean', description: 'Whether to accept personal emails' }, - callback_url: { type: 'string', description: 'Callback URL' }, - id: { type: 'string', description: 'Individual reveal ID' }, }, outputs: { @@ -495,46 +482,44 @@ Return ONLY the JSON object - no explanations, no extra text.`, }, id: { type: 'number', - description: 'Reveal ID (start_individual_reveal, get_individual_reveal)', + description: 'Reveal ID (individual_reveal)', }, status: { type: 'string', - description: - 'Reveal status (start_individual_reveal, get_individual_reveal): queued | resolving | finished | failed', + description: 'Reveal status (individual_reveal): queued | resolving | finished | failed', }, is_complete: { type: 'boolean', - description: - 'Whether the reveal has completed (start_individual_reveal, get_individual_reveal)', + description: 'Whether the reveal has completed (individual_reveal)', }, - name: { type: 'string', description: 'Full name (get_individual_reveal)' }, - company: { type: 'string', description: 'Company name (get_individual_reveal)' }, + name: { type: 'string', description: 'Full name (individual_reveal)' }, + company: { type: 'string', description: 'Company name (individual_reveal)' }, enrichment_level: { type: 'string', - description: 'Enrichment level used (get_individual_reveal)', + description: 'Enrichment level used (individual_reveal)', }, - linkedin_profile_url: { type: 'string', description: 'LinkedIn URL (get_individual_reveal)' }, - title: { type: 'string', description: 'Job title (get_individual_reveal)' }, - location: { type: 'string', description: 'Location (get_individual_reveal)' }, - email: { type: 'string', description: 'Primary email (get_individual_reveal)' }, - email_type: { type: 'string', description: 'Primary email type (get_individual_reveal)' }, + linkedin_profile_url: { type: 'string', description: 'LinkedIn URL (individual_reveal)' }, + title: { type: 'string', description: 'Job title (individual_reveal)' }, + location: { type: 'string', description: 'Location (individual_reveal)' }, + email: { type: 'string', description: 'Primary email (individual_reveal)' }, + email_type: { type: 'string', description: 'Primary email type (individual_reveal)' }, email_status: { type: 'string', - description: 'Primary email status: valid | risky | unfound (get_individual_reveal)', + description: 'Primary email status: valid | risky | unfound (individual_reveal)', }, emails: { type: 'json', - description: 'All emails found (get_individual_reveal): [{email, email_type, email_status}]', + description: 'All emails found (individual_reveal): [{email, email_type, email_status}]', }, - mobile_phone: { type: 'string', description: 'Mobile phone (get_individual_reveal)' }, - phone_number: { type: 'string', description: 'Direct/office phone (get_individual_reveal)' }, + mobile_phone: { type: 'string', description: 'Mobile phone (individual_reveal)' }, + phone_number: { type: 'string', description: 'Direct/office phone (individual_reveal)' }, phone_status: { type: 'string', - description: 'Phone status: found | unfound (get_individual_reveal)', + description: 'Phone status: found | unfound (individual_reveal)', }, phones: { type: 'json', - description: 'All phones found (get_individual_reveal): [{number, pretty_number, type}]', + description: 'All phones found (individual_reveal): [{number, pretty_number, type}]', }, company_name: { type: 'string', @@ -542,41 +527,41 @@ Return ONLY the JSON object - no explanations, no extra text.`, }, company_domain: { type: 'string', - description: 'Company domain (company_enrichment, get_individual_reveal)', + description: 'Company domain (company_enrichment, individual_reveal)', }, domain: { type: 'string', description: 'Domain (company_enrichment)' }, company_industry: { type: 'string', - description: 'Industry (company_enrichment, get_individual_reveal)', + description: 'Industry (company_enrichment, individual_reveal)', }, company_size: { type: 'number', - description: 'Employee count (company_enrichment, get_individual_reveal)', + description: 'Employee count (company_enrichment, individual_reveal)', }, company_size_range: { type: 'string', - description: 'Headcount range (company_enrichment, get_individual_reveal)', + description: 'Headcount range (company_enrichment, individual_reveal)', }, company_founded: { type: 'number', - description: 'Year founded (company_enrichment, get_individual_reveal)', + description: 'Year founded (company_enrichment, individual_reveal)', }, company_revenue_range: { type: 'string', description: 'Revenue range (company_enrichment)', }, - company_revenue: { type: 'string', description: 'Revenue (get_individual_reveal)' }, + company_revenue: { type: 'string', description: 'Revenue (individual_reveal)' }, company_funding: { type: 'string', - description: 'Total funding (company_enrichment, get_individual_reveal)', + description: 'Total funding (company_enrichment, individual_reveal)', }, company_type: { type: 'string', - description: 'Company type (company_enrichment, get_individual_reveal)', + description: 'Company type (company_enrichment, individual_reveal)', }, company_description: { type: 'string', - description: 'Company description (company_enrichment, get_individual_reveal)', + description: 'Company description (company_enrichment, individual_reveal)', }, company_ticker: { type: 'string', description: 'Stock ticker (company_enrichment)' }, company_last_funding_round: { @@ -593,40 +578,40 @@ Return ONLY the JSON object - no explanations, no extra text.`, }, company_location: { type: 'string', - description: 'Full location string (company_enrichment, get_individual_reveal)', + description: 'Full location string (company_enrichment, individual_reveal)', }, company_twitter: { type: 'string', description: 'Twitter URL (company_enrichment)' }, company_facebook: { type: 'string', description: 'Facebook URL (company_enrichment)' }, company_linkedin: { type: 'string', - description: 'LinkedIn URL (company_enrichment, get_individual_reveal)', + description: 'LinkedIn URL (company_enrichment, individual_reveal)', }, company_linkedin_id: { type: 'string', description: 'LinkedIn ID (company_enrichment)' }, company_street: { type: 'string', - description: 'Street address (company_enrichment, get_individual_reveal)', + description: 'Street address (company_enrichment, individual_reveal)', }, company_locality: { type: 'string', - description: 'City (company_enrichment, get_individual_reveal)', + description: 'City (company_enrichment, individual_reveal)', }, company_region: { type: 'string', - description: 'State/region (company_enrichment, get_individual_reveal)', + description: 'State/region (company_enrichment, individual_reveal)', }, company_postal_code: { type: 'string', - description: 'Postal code (company_enrichment, get_individual_reveal)', + description: 'Postal code (company_enrichment, individual_reveal)', }, company_country: { type: 'string', - description: 'Country (company_enrichment, get_individual_reveal)', + description: 'Country (company_enrichment, individual_reveal)', }, - company_subindustry: { type: 'string', description: 'Subindustry (get_individual_reveal)' }, + company_subindustry: { type: 'string', description: 'Subindustry (individual_reveal)' }, credits: { type: 'json', description: - 'Credits deducted — company_enrichment: { api_credits: { total, company_credits } }; get_individual_reveal: { api_credits: { total, email_credits, phone_credits, scrape_credits } }', + 'Credits deducted — company_enrichment: { api_credits: { total, company_credits } }; individual_reveal: { api_credits: { total, email_credits, phone_credits, scrape_credits } }', }, email_credits: { type: 'json', diff --git a/apps/sim/enrichments/phone-number/phone-number.test.ts b/apps/sim/enrichments/phone-number/phone-number.test.ts new file mode 100644 index 0000000000..2aa82c33ee --- /dev/null +++ b/apps/sim/enrichments/phone-number/phone-number.test.ts @@ -0,0 +1,78 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { phoneNumberEnrichment } from '@/enrichments/phone-number/phone-number' +import type { EnrichmentProvider } from '@/enrichments/types' + +function provider(id: string): EnrichmentProvider { + const p = phoneNumberEnrichment.providers.find((x) => x.id === id) + if (!p) throw new Error(`Provider ${id} not found in phone-number cascade`) + return p +} + +const nameDomain = { fullName: 'John Doe', companyDomain: 'https://www.acme.com/careers' } +const linkedinOnly = { fullName: 'John Doe', linkedinUrl: 'https://linkedin.com/in/johndoe' } + +describe('phone-number enrichment cascade', () => { + it('chains PDL then the phone-capable hosted providers', () => { + expect(phoneNumberEnrichment.providers.map((p) => p.id)).toEqual([ + 'pdl', + 'wiza', + 'findymail', + 'prospeo', + ]) + }) + + describe('wiza (opportunistic)', () => { + const p = provider('wiza') + it('reveals phone, using name+domain or LinkedIn profile_url', () => { + expect(p.toolId).toBe('wiza_individual_reveal') + expect(p.buildParams(nameDomain)).toEqual({ + full_name: 'John Doe', + domain: 'acme.com', + enrichment_level: 'phone', + }) + expect(p.buildParams(linkedinOnly)).toEqual({ + full_name: 'John Doe', + profile_url: 'https://linkedin.com/in/johndoe', + enrichment_level: 'phone', + }) + expect(p.buildParams({ fullName: 'John Doe' })).toBeNull() + expect(p.mapOutput({ mobile_phone: '+1555', phones: [] })).toEqual({ phone: '+1555' }) + expect(p.mapOutput({ phones: [{ number: '+1777' }] })).toEqual({ phone: '+1777' }) + }) + }) + + describe('findymail', () => { + const p = provider('findymail') + it('keys off the LinkedIn URL and skips without one', () => { + expect(p.toolId).toBe('findymail_find_phone') + expect(p.buildParams(linkedinOnly)).toEqual({ + linkedin_url: 'https://linkedin.com/in/johndoe', + }) + expect(p.buildParams(nameDomain)).toBeNull() + expect(p.mapOutput({ phone: '+1555' })).toEqual({ phone: '+1555' }) + expect(p.mapOutput({ phone: null })).toBeNull() + }) + }) + + describe('prospeo (opportunistic)', () => { + const p = provider('prospeo') + it('requests mobile enrichment via name+domain or LinkedIn', () => { + expect(p.toolId).toBe('prospeo_enrich_person') + expect(p.buildParams(nameDomain)).toEqual({ + full_name: 'John Doe', + company_website: 'acme.com', + enrich_mobile: true, + }) + expect(p.buildParams(linkedinOnly)).toEqual({ + full_name: 'John Doe', + linkedin_url: 'https://linkedin.com/in/johndoe', + enrich_mobile: true, + }) + expect(p.buildParams({ fullName: 'John Doe' })).toBeNull() + expect(p.mapOutput({ person: { mobile: { mobile: '+1555' } } })).toEqual({ phone: '+1555' }) + }) + }) +}) diff --git a/apps/sim/enrichments/phone-number/phone-number.ts b/apps/sim/enrichments/phone-number/phone-number.ts index 5b76e12e80..6a7ad186cd 100644 --- a/apps/sim/enrichments/phone-number/phone-number.ts +++ b/apps/sim/enrichments/phone-number/phone-number.ts @@ -4,17 +4,22 @@ import { firstNonEmpty, normalizeDomain, str, toolProvider } from '@/enrichments import type { EnrichmentConfig } from '@/enrichments/types' /** - * Phone Number enrichment. Finds a contact's phone number from their full name - * and (optionally) company domain via a People Data Labs person match. + * Phone Number enrichment. Finds a contact's phone number from a full name plus + * any available identifiers (company domain, LinkedIn URL) via a waterfall: + * People Data Labs (name match) → Wiza reveal → Findymail (LinkedIn) → Prospeo + * mobile. Each provider opportunistically uses whatever identifiers the row + * provides and self-skips when it has none usable, so adding more inputs widens + * coverage without reordering. First phone wins; all providers support hosted keys. */ export const phoneNumberEnrichment: EnrichmentConfig = { id: 'phone-number', name: 'Phone Number', - description: "Find a contact's phone number from their name and company domain.", + description: "Find a contact's phone number from their name, company, or LinkedIn URL.", icon: Phone, inputs: [ { id: 'fullName', name: 'Full name', type: 'string', required: true }, { id: 'companyDomain', name: 'Company domain', type: 'string' }, + { id: 'linkedinUrl', name: 'LinkedIn URL', type: 'string' }, ], outputs: [{ id: 'phone', name: 'phone', type: 'string' }], providers: [ @@ -37,5 +42,69 @@ export const phoneNumberEnrichment: EnrichmentConfig = { return phone ? { phone } : null }, }), + toolProvider({ + id: 'wiza', + label: 'Wiza', + toolId: 'wiza_individual_reveal', + buildParams: (inputs) => { + const linkedin = str(inputs.linkedinUrl) + const fullName = str(inputs.fullName) + const domain = normalizeDomain(inputs.companyDomain) + // Needs a LinkedIn URL or a name+domain pair; skip otherwise. + if (!linkedin && !(fullName && domain)) return null + // 'phone' reveals the mobile number (5 credits). Prefer LinkedIn when present. + return filterUndefined({ + profile_url: linkedin || undefined, + full_name: fullName || undefined, + domain: domain || undefined, + enrichment_level: 'phone', + }) + }, + mapOutput: (output) => { + const phones = Array.isArray(output.phones) + ? (output.phones as Record[]) + : [] + const phone = str(output.mobile_phone) || str(output.phone_number) || str(phones[0]?.number) + return phone ? { phone } : null + }, + }), + toolProvider({ + id: 'findymail', + label: 'Findymail', + toolId: 'findymail_find_phone', + buildParams: (inputs) => { + // Findymail's phone finder keys off a LinkedIn URL only. + const linkedin = str(inputs.linkedinUrl) + if (!linkedin) return null + return { linkedin_url: linkedin } + }, + mapOutput: (output) => { + const phone = str(output.phone) + return phone ? { phone } : null + }, + }), + toolProvider({ + id: 'prospeo', + label: 'Prospeo', + toolId: 'prospeo_enrich_person', + buildParams: (inputs) => { + const linkedin = str(inputs.linkedinUrl) + const fullName = str(inputs.fullName) + const companyWebsite = normalizeDomain(inputs.companyDomain) + if (!linkedin && !(fullName && companyWebsite)) return null + return filterUndefined({ + linkedin_url: linkedin || undefined, + full_name: fullName || undefined, + company_website: companyWebsite || undefined, + enrich_mobile: true, + }) + }, + mapOutput: (output) => { + const person = output.person as Record | undefined + const mobile = person?.mobile as Record | undefined + const phone = str(mobile?.mobile) + return phone ? { phone } : null + }, + }), ], } diff --git a/apps/sim/enrichments/work-email/work-email.test.ts b/apps/sim/enrichments/work-email/work-email.test.ts new file mode 100644 index 0000000000..41bf50bc23 --- /dev/null +++ b/apps/sim/enrichments/work-email/work-email.test.ts @@ -0,0 +1,86 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import type { EnrichmentProvider } from '@/enrichments/types' +import { workEmailEnrichment } from '@/enrichments/work-email/work-email' + +function provider(id: string): EnrichmentProvider { + const p = workEmailEnrichment.providers.find((x) => x.id === id) + if (!p) throw new Error(`Provider ${id} not found in work-email cascade`) + return p +} + +const nameDomain = { fullName: 'John Doe', companyDomain: 'https://www.acme.com/careers' } +const linkedinOnly = { fullName: 'John Doe', linkedinUrl: 'https://linkedin.com/in/johndoe' } + +describe('work-email enrichment cascade', () => { + it('chains the hosted providers in waterfall order', () => { + expect(workEmailEnrichment.providers.map((p) => p.id)).toEqual([ + 'hunter', + 'findymail', + 'findymail-linkedin', + 'prospeo', + 'wiza', + 'pdl', + ]) + }) + + describe('findymail (name)', () => { + const p = provider('findymail') + it('maps name + domain and extracts contact.email', () => { + expect(p.toolId).toBe('findymail_find_email_from_name') + expect(p.buildParams(nameDomain)).toEqual({ name: 'John Doe', domain: 'acme.com' }) + expect(p.mapOutput({ contact: { email: 'j@acme.com' } })).toEqual({ email: 'j@acme.com' }) + expect(p.buildParams(linkedinOnly)).toBeNull() + }) + }) + + describe('findymail-linkedin', () => { + const p = provider('findymail-linkedin') + it('keys off the LinkedIn URL and skips without one', () => { + expect(p.toolId).toBe('findymail_find_email_from_linkedin') + expect(p.buildParams(linkedinOnly)).toEqual({ + linkedin_url: 'https://linkedin.com/in/johndoe', + }) + expect(p.buildParams(nameDomain)).toBeNull() + expect(p.mapOutput({ contact: { email: 'j@acme.com' } })).toEqual({ email: 'j@acme.com' }) + }) + }) + + describe('prospeo (opportunistic)', () => { + const p = provider('prospeo') + it('uses name+domain, or LinkedIn when present', () => { + expect(p.buildParams(nameDomain)).toEqual({ + full_name: 'John Doe', + company_website: 'acme.com', + }) + expect(p.buildParams(linkedinOnly)).toEqual({ + full_name: 'John Doe', + linkedin_url: 'https://linkedin.com/in/johndoe', + }) + expect(p.buildParams({ fullName: 'John Doe' })).toBeNull() + expect(p.mapOutput({ person: { email: { email: 'j@acme.com' } } })).toEqual({ + email: 'j@acme.com', + }) + }) + }) + + describe('wiza (opportunistic)', () => { + const p = provider('wiza') + it('reveals email-only (partial), preferring LinkedIn profile_url', () => { + expect(p.buildParams(nameDomain)).toEqual({ + full_name: 'John Doe', + domain: 'acme.com', + enrichment_level: 'partial', + }) + expect(p.buildParams(linkedinOnly)).toEqual({ + full_name: 'John Doe', + profile_url: 'https://linkedin.com/in/johndoe', + enrichment_level: 'partial', + }) + expect(p.buildParams({ fullName: 'John Doe' })).toBeNull() + expect(p.mapOutput({ email: 'j@acme.com' })).toEqual({ email: 'j@acme.com' }) + }) + }) +}) diff --git a/apps/sim/enrichments/work-email/work-email.ts b/apps/sim/enrichments/work-email/work-email.ts index 0e998406c3..4166217a1c 100644 --- a/apps/sim/enrichments/work-email/work-email.ts +++ b/apps/sim/enrichments/work-email/work-email.ts @@ -4,18 +4,23 @@ import { normalizeDomain, splitName, str, toolProvider } from '@/enrichments/pro import type { EnrichmentConfig } from '@/enrichments/types' /** - * Work Email enrichment. Finds a person's work email from their full name and - * company domain, trying Hunter first (deterministic finder) then People Data - * Labs (record match) as a fallback. + * Work Email enrichment. Finds a person's work email from a full name plus any + * available identifiers (company domain, LinkedIn URL) via a provider waterfall: + * deterministic finders first (Hunter, Findymail by name then by LinkedIn), then + * enrichment/reveal providers (Prospeo, Wiza), then People Data Labs as a broad + * record-match fallback. Each provider opportunistically uses whatever + * identifiers the row provides and self-skips when it has none usable, so adding + * more inputs widens coverage. First email wins; all providers support hosted keys. */ export const workEmailEnrichment: EnrichmentConfig = { id: 'work-email', name: 'Work Email', - description: "Find a person's work email from their name and company domain.", + description: "Find a person's work email from their name, company, or LinkedIn URL.", icon: Mail, inputs: [ { id: 'fullName', name: 'Full name', type: 'string', required: true }, - { id: 'companyDomain', name: 'Company domain', type: 'string', required: true }, + { id: 'companyDomain', name: 'Company domain', type: 'string' }, + { id: 'linkedinUrl', name: 'LinkedIn URL', type: 'string' }, ], outputs: [{ id: 'email', name: 'email', type: 'string' }], providers: [ @@ -34,6 +39,81 @@ export const workEmailEnrichment: EnrichmentConfig = { return email ? { email } : null }, }), + toolProvider({ + id: 'findymail', + label: 'Findymail', + toolId: 'findymail_find_email_from_name', + buildParams: (inputs) => { + const name = str(inputs.fullName) + const domain = normalizeDomain(inputs.companyDomain) + if (!name || !domain) return null + return { name, domain } + }, + mapOutput: (output) => { + const contact = output.contact as Record | null + const email = str(contact?.email) + return email ? { email } : null + }, + }), + toolProvider({ + id: 'findymail-linkedin', + label: 'Findymail (LinkedIn)', + toolId: 'findymail_find_email_from_linkedin', + buildParams: (inputs) => { + const linkedin = str(inputs.linkedinUrl) + if (!linkedin) return null + return { linkedin_url: linkedin } + }, + mapOutput: (output) => { + const contact = output.contact as Record | null + const email = str(contact?.email) + return email ? { email } : null + }, + }), + toolProvider({ + id: 'prospeo', + label: 'Prospeo', + toolId: 'prospeo_enrich_person', + buildParams: (inputs) => { + const linkedin = str(inputs.linkedinUrl) + const fullName = str(inputs.fullName) + const companyWebsite = normalizeDomain(inputs.companyDomain) + if (!linkedin && !(fullName && companyWebsite)) return null + return filterUndefined({ + linkedin_url: linkedin || undefined, + full_name: fullName || undefined, + company_website: companyWebsite || undefined, + }) + }, + mapOutput: (output) => { + const person = output.person as Record | undefined + const emailObj = person?.email as Record | undefined + const email = str(emailObj?.email) + return email ? { email } : null + }, + }), + toolProvider({ + id: 'wiza', + label: 'Wiza', + toolId: 'wiza_individual_reveal', + buildParams: (inputs) => { + const linkedin = str(inputs.linkedinUrl) + const fullName = str(inputs.fullName) + const domain = normalizeDomain(inputs.companyDomain) + if (!linkedin && !(fullName && domain)) return null + // 'partial' reveals the email only (2 credits); avoids phone charges. + return filterUndefined({ + profile_url: linkedin || undefined, + full_name: fullName || undefined, + domain: domain || undefined, + enrichment_level: 'partial', + }) + }, + mapOutput: (output) => { + const email = str(output.email) + return email ? { email } : null + }, + }), toolProvider({ id: 'pdl', label: 'People Data Labs', diff --git a/apps/sim/lib/api/contracts/byok-keys.ts b/apps/sim/lib/api/contracts/byok-keys.ts index d52838eb42..0f79dcb217 100644 --- a/apps/sim/lib/api/contracts/byok-keys.ts +++ b/apps/sim/lib/api/contracts/byok-keys.ts @@ -20,6 +20,9 @@ export const byokProviderIdSchema = z.enum([ 'cohere', 'hunter', 'peopledatalabs', + 'findymail', + 'prospeo', + 'wiza', ]) export const byokKeySchema = z.object({ diff --git a/apps/sim/tools/enrichment-hosting.test.ts b/apps/sim/tools/enrichment-hosting.test.ts new file mode 100644 index 0000000000..fcb33b31d0 --- /dev/null +++ b/apps/sim/tools/enrichment-hosting.test.ts @@ -0,0 +1,277 @@ +/** + * @vitest-environment node + */ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { findEmailFromNameTool } from '@/tools/findymail/find_email_from_name' +import { findEmailsByDomainTool } from '@/tools/findymail/find_emails_by_domain' +import { findPhoneTool } from '@/tools/findymail/find_phone' +import { FINDYMAIL_CREDIT_USD } from '@/tools/findymail/hosting' +import { reverseEmailLookupTool } from '@/tools/findymail/reverse_email_lookup' +import { verifyEmailTool } from '@/tools/findymail/verify_email' +import { bulkEnrichPersonTool } from '@/tools/prospeo/bulk_enrich_person' +import { enrichCompanyTool } from '@/tools/prospeo/enrich_company' +import { enrichPersonTool } from '@/tools/prospeo/enrich_person' +import { PROSPEO_CREDIT_USD } from '@/tools/prospeo/hosting' +import { searchPersonTool } from '@/tools/prospeo/search_person' +import type { ToolConfig } from '@/tools/types' +import { wizaCompanyEnrichmentTool } from '@/tools/wiza/company_enrichment' +import { WIZA_CREDIT_USD } from '@/tools/wiza/hosting' +import { wizaIndividualRevealTool } from '@/tools/wiza/individual_reveal' +import { wizaProspectSearchTool } from '@/tools/wiza/prospect_search' + +afterEach(() => { + vi.useRealTimers() + vi.unstubAllGlobals() +}) + +function cost(tool: ToolConfig, params: any, output: Record) { + const pricing = tool.hosting?.pricing + if (!pricing || pricing.type !== 'custom') throw new Error('Expected custom pricing') + const result = pricing.getCost(params, output) + return typeof result === 'number' ? { cost: result } : result +} + +describe('Findymail hosted key pricing', () => { + it('declares hosting with the shared env prefix and BYOK provider', () => { + expect(findEmailFromNameTool.hosting?.envKeyPrefix).toBe('FINDYMAIL_API_KEY') + expect(findEmailFromNameTool.hosting?.byokProviderId).toBe('findymail') + }) + + it('charges one credit only when an email is found', () => { + expect(cost(findEmailFromNameTool, {}, { contact: { email: 'a@b.com' } }).cost).toBeCloseTo( + FINDYMAIL_CREDIT_USD + ) + expect(cost(findEmailFromNameTool, {}, { contact: null }).cost).toBe(0) + }) + + it('charges 10 credits for a found phone', () => { + expect(cost(findPhoneTool, {}, { phone: '+1555' }).cost).toBeCloseTo(10 * FINDYMAIL_CREDIT_USD) + expect(cost(findPhoneTool, {}, { phone: null }).cost).toBe(0) + }) + + it('charges one credit per contact returned by domain search', () => { + expect(cost(findEmailsByDomainTool, {}, { contacts: [{}, {}, {}] }).cost).toBeCloseTo( + 3 * FINDYMAIL_CREDIT_USD + ) + }) + + it('charges 2 credits for a reverse lookup with profile enrichment, 1 without', () => { + expect( + cost(reverseEmailLookupTool, { with_profile: true }, { email: 'a@b.com' }).cost + ).toBeCloseTo(2 * FINDYMAIL_CREDIT_USD) + expect( + cost(reverseEmailLookupTool, { with_profile: false }, { email: 'a@b.com' }).cost + ).toBeCloseTo(FINDYMAIL_CREDIT_USD) + expect( + cost(reverseEmailLookupTool, {}, { email: null, linkedin_url: null, fullName: null }).cost + ).toBe(0) + }) + + it('charges one verifier credit per verification', () => { + expect(cost(verifyEmailTool, {}, { verified: true }).cost).toBeCloseTo(FINDYMAIL_CREDIT_USD) + }) +}) + +describe('Prospeo hosted key pricing', () => { + it('declares hosting with the shared env prefix and BYOK provider', () => { + expect(enrichPersonTool.hosting?.envKeyPrefix).toBe('PROSPEO_API_KEY') + expect(enrichPersonTool.hosting?.byokProviderId).toBe('prospeo') + }) + + it('charges 1 credit for a person match and 10 when a mobile is revealed', () => { + expect(cost(enrichPersonTool, {}, { free_enrichment: false, person: {} }).cost).toBeCloseTo( + PROSPEO_CREDIT_USD + ) + expect( + cost(enrichPersonTool, {}, { free_enrichment: false, person: { mobile: { revealed: true } } }) + .cost + ).toBeCloseTo(10 * PROSPEO_CREDIT_USD) + }) + + it('does not charge on a free or no-match enrichment', () => { + expect(cost(enrichPersonTool, {}, { free_enrichment: true, person: {} }).cost).toBe(0) + expect(cost(enrichPersonTool, {}, { free_enrichment: false, person: null }).cost).toBe(0) + expect(cost(enrichCompanyTool, {}, { free_enrichment: false, company: null }).cost).toBe(0) + }) + + it('uses the API-reported total_cost for bulk endpoints', () => { + expect(cost(bulkEnrichPersonTool, {}, { total_cost: 7 }).cost).toBeCloseTo( + 7 * PROSPEO_CREDIT_USD + ) + }) + + it('throws when bulk total_cost is missing', () => { + expect(() => cost(bulkEnrichPersonTool, {}, { matched: [] })).toThrow(/total_cost/) + }) + + it('charges one credit per non-free search page with results', () => { + expect(cost(searchPersonTool, {}, { free: false, results: [{}] }).cost).toBeCloseTo( + PROSPEO_CREDIT_USD + ) + expect(cost(searchPersonTool, {}, { free: true, results: [{}] }).cost).toBe(0) + expect(cost(searchPersonTool, {}, { free: false, results: [] }).cost).toBe(0) + }) +}) + +describe('Wiza hosted key pricing', () => { + it('declares hosting with the shared env prefix and BYOK provider', () => { + expect(wizaIndividualRevealTool.hosting?.envKeyPrefix).toBe('WIZA_API_KEY') + expect(wizaIndividualRevealTool.hosting?.byokProviderId).toBe('wiza') + }) + + it('charges 2 credits for a valid email and 5 for a phone on individual reveal', () => { + expect( + cost(wizaIndividualRevealTool, {}, { email_status: 'valid', phones: [] }).cost + ).toBeCloseTo(2 * WIZA_CREDIT_USD) + expect( + cost(wizaIndividualRevealTool, {}, { email_status: 'unfound', mobile_phone: '+1555' }).cost + ).toBeCloseTo(5 * WIZA_CREDIT_USD) + expect( + cost(wizaIndividualRevealTool, {}, { email_status: 'valid', phones: [{ number: '+1555' }] }) + .cost + ).toBeCloseTo(7 * WIZA_CREDIT_USD) + expect(cost(wizaIndividualRevealTool, {}, { email_status: 'unfound', phones: [] }).cost).toBe(0) + }) + + it('charges 2 credits per company enrichment match and nothing for prospect search', () => { + expect(cost(wizaCompanyEnrichmentTool, {}, { company_name: 'Wiza' }).cost).toBeCloseTo( + 2 * WIZA_CREDIT_USD + ) + expect( + cost( + wizaCompanyEnrichmentTool, + {}, + { company_name: null, company_domain: null, domain: null } + ).cost + ).toBe(0) + expect(cost(wizaProspectSearchTool, {}, { total: 100, profiles: [] }).cost).toBe(0) + }) + + it('polls the reveal to completion in postProcess', async () => { + vi.useFakeTimers() + const fetchMock = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + data: { + id: 123, + status: 'finished', + email: 'a@b.com', + email_status: 'valid', + emails: [], + phones: [], + }, + }), + { status: 200, headers: { 'Content-Type': 'application/json' } } + ) + ) + vi.stubGlobal('fetch', fetchMock) + + const initial = { + success: true as const, + output: { id: 123, status: 'queued', is_complete: false } as any, + } + const promise = wizaIndividualRevealTool.postProcess!( + initial as any, + { apiKey: 'k', enrichment_level: 'full' } as any, + vi.fn() + ) + await vi.advanceTimersByTimeAsync(2000) + const result = await promise + + expect(fetchMock).toHaveBeenCalledWith( + 'https://wiza.co/api/individual_reveals/123', + expect.objectContaining({ headers: expect.objectContaining({ Authorization: 'Bearer k' }) }) + ) + expect(result.success).toBe(true) + expect((result.output as any).email).toBe('a@b.com') + expect((result.output as any).status).toBe('finished') + }) + + it('returns immediately without polling when the initial reveal is already terminal', async () => { + const fetchMock = vi.fn() + vi.stubGlobal('fetch', fetchMock) + + const initial = { + success: true as const, + output: { + id: 123, + status: 'finished', + is_complete: true, + email: 'a@b.com', + email_status: 'valid', + emails: [], + phones: [], + } as any, + } + const result = await wizaIndividualRevealTool.postProcess!( + initial as any, + { apiKey: 'k', enrichment_level: 'full' } as any, + vi.fn() + ) + + expect(fetchMock).not.toHaveBeenCalled() + expect(result.success).toBe(true) + expect((result.output as any).email).toBe('a@b.com') + }) + + it('retries transient poll errors and still resolves on a later finished response', async () => { + vi.useFakeTimers() + const fetchMock = vi + .fn() + .mockResolvedValueOnce(new Response('busy', { status: 503 })) + .mockResolvedValueOnce(new Response('rate limited', { status: 429 })) + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + data: { + id: 1, + status: 'finished', + email: 'a@b.com', + email_status: 'valid', + emails: [], + phones: [], + }, + }), + { status: 200, headers: { 'Content-Type': 'application/json' } } + ) + ) + vi.stubGlobal('fetch', fetchMock) + + const initial = { + success: true as const, + output: { id: 1, status: 'queued', is_complete: false } as any, + } + const promise = wizaIndividualRevealTool.postProcess!( + initial as any, + { apiKey: 'k', enrichment_level: 'full' } as any, + vi.fn() + ) + await vi.advanceTimersByTimeAsync(6000) + const result = await promise + + expect(fetchMock).toHaveBeenCalledTimes(3) + expect(result.success).toBe(true) + expect((result.output as any).email).toBe('a@b.com') + }) + + it('returns an explicit failure (not a queued success) after repeated poll errors', async () => { + vi.useFakeTimers() + const fetchMock = vi.fn().mockResolvedValue(new Response('error', { status: 500 })) + vi.stubGlobal('fetch', fetchMock) + + const initial = { + success: true as const, + output: { id: 1, status: 'queued', is_complete: false } as any, + } + const promise = wizaIndividualRevealTool.postProcess!( + initial as any, + { apiKey: 'k', enrichment_level: 'full' } as any, + vi.fn() + ) + await vi.advanceTimersByTimeAsync(6000) + const result = await promise + + expect(result.success).toBe(false) + expect((result.output as any).status).toBe('queued') + }) +}) diff --git a/apps/sim/tools/findymail/find_email_from_linkedin.ts b/apps/sim/tools/findymail/find_email_from_linkedin.ts index 87eb569f96..5d095cd9cf 100644 --- a/apps/sim/tools/findymail/find_email_from_linkedin.ts +++ b/apps/sim/tools/findymail/find_email_from_linkedin.ts @@ -1,3 +1,4 @@ +import { findymailHosting } from '@/tools/findymail/hosting' import type { FindymailFindEmailFromLinkedInParams, FindymailFindEmailFromLinkedInResponse, @@ -15,6 +16,11 @@ export const findEmailFromLinkedInTool: ToolConfig< "Find someone's email from a LinkedIn profile URL or username. Uses one finder credit when a verified email is found.", version: '1.0.0', + hosting: findymailHosting((_params, output) => { + const contact = output.contact as { email?: string } | null + return contact?.email ? 1 : 0 + }), + params: { linkedin_url: { type: 'string', diff --git a/apps/sim/tools/findymail/find_email_from_name.ts b/apps/sim/tools/findymail/find_email_from_name.ts index 5a22a19c3b..6a09ed75bd 100644 --- a/apps/sim/tools/findymail/find_email_from_name.ts +++ b/apps/sim/tools/findymail/find_email_from_name.ts @@ -1,3 +1,4 @@ +import { findymailHosting } from '@/tools/findymail/hosting' import type { FindymailFindEmailFromNameParams, FindymailFindEmailFromNameResponse, @@ -15,6 +16,11 @@ export const findEmailFromNameTool: ToolConfig< "Find someone's email from their name and a company domain or company name. Uses one finder credit when a verified email is found.", version: '1.0.0', + hosting: findymailHosting((_params, output) => { + const contact = output.contact as { email?: string } | null + return contact?.email ? 1 : 0 + }), + params: { name: { type: 'string', diff --git a/apps/sim/tools/findymail/find_emails_by_domain.ts b/apps/sim/tools/findymail/find_emails_by_domain.ts index ebb1de7ee3..de26fd4f0a 100644 --- a/apps/sim/tools/findymail/find_emails_by_domain.ts +++ b/apps/sim/tools/findymail/find_emails_by_domain.ts @@ -1,3 +1,4 @@ +import { findymailHosting } from '@/tools/findymail/hosting' import type { FindymailFindEmailsByDomainParams, FindymailFindEmailsByDomainResponse, @@ -15,6 +16,15 @@ export const findEmailsByDomainTool: ToolConfig< 'Find verified contacts at a given domain matching one or more target roles (max 3 roles). Limited to 5 concurrent synchronous requests.', version: '1.0.0', + hosting: findymailHosting((_params, output) => { + // No contacts array means no verified contacts returned — no charge. + if (!Array.isArray(output.contacts)) { + return 0 + } + // 1 finder credit per verified contact returned. + return output.contacts.length + }), + params: { domain: { type: 'string', diff --git a/apps/sim/tools/findymail/find_employees.ts b/apps/sim/tools/findymail/find_employees.ts index aabd08ce47..9051ad8d75 100644 --- a/apps/sim/tools/findymail/find_employees.ts +++ b/apps/sim/tools/findymail/find_employees.ts @@ -1,3 +1,4 @@ +import { findymailHosting } from '@/tools/findymail/hosting' import type { FindymailFindEmployeesParams, FindymailFindEmployeesResponse, @@ -15,6 +16,15 @@ export const findEmployeesTool: ToolConfig< 'Find employees at a company by website and target job titles. Uses 1 credit per found contact. Does not return email addresses.', version: '1.0.0', + hosting: findymailHosting((_params, output) => { + // No employees array means no contacts found — no charge. + if (!Array.isArray(output.employees)) { + return 0 + } + // 1 finder credit per contact found. + return output.employees.length + }), + params: { website: { type: 'string', diff --git a/apps/sim/tools/findymail/find_phone.ts b/apps/sim/tools/findymail/find_phone.ts index 0222eeed79..8a5c48f159 100644 --- a/apps/sim/tools/findymail/find_phone.ts +++ b/apps/sim/tools/findymail/find_phone.ts @@ -1,3 +1,4 @@ +import { findymailHosting } from '@/tools/findymail/hosting' import type { FindymailFindPhoneParams, FindymailFindPhoneResponse } from '@/tools/findymail/types' import type { ToolConfig } from '@/tools/types' @@ -8,6 +9,11 @@ export const findPhoneTool: ToolConfig((_params, output) => { + // Phone lookups consume 10 finder credits, only when a number is found. + return output.phone ? 10 : 0 + }), + params: { linkedin_url: { type: 'string', diff --git a/apps/sim/tools/findymail/get_company.ts b/apps/sim/tools/findymail/get_company.ts index 2ee001f852..64c03bcf83 100644 --- a/apps/sim/tools/findymail/get_company.ts +++ b/apps/sim/tools/findymail/get_company.ts @@ -1,3 +1,4 @@ +import { findymailHosting } from '@/tools/findymail/hosting' import type { FindymailGetCompanyParams, FindymailGetCompanyResponse, @@ -11,6 +12,11 @@ export const getCompanyTool: ToolConfig((_params, output) => { + // 1 finder credit per successful company match. + return output.name || output.domain ? 1 : 0 + }), + params: { linkedin_url: { type: 'string', diff --git a/apps/sim/tools/findymail/hosting.ts b/apps/sim/tools/findymail/hosting.ts new file mode 100644 index 0000000000..e624c13163 --- /dev/null +++ b/apps/sim/tools/findymail/hosting.ts @@ -0,0 +1,42 @@ +import type { ToolHostingConfig } from '@/tools/types' + +/** + * Env var prefix for Findymail hosted keys. Provide keys as + * `FINDYMAIL_API_KEY_COUNT` plus `FINDYMAIL_API_KEY_1..N`. + */ +export const FINDYMAIL_API_KEY_PREFIX = 'FINDYMAIL_API_KEY' + +/** + * Dollar cost of a single Findymail finder credit. + * + * Findymail charges per verified result: 1 credit per email, 10 credits per + * phone, and only when a result is found. Estimated from the $99/month Starter + * plan (5,000 credits ≈ $0.0198/credit) — https://www.findymail.com/pricing/. + */ +export const FINDYMAIL_CREDIT_USD = 0.02 + +/** + * Build a Findymail `hosting` config. `getCredits` returns the number of + * Findymail credits the call consumed, derived from the tool's output (per the + * documented per-endpoint credit model at https://www.findymail.com/api/). + */ +export function findymailHosting

( + getCredits: (params: P, output: Record) => number +): ToolHostingConfig

{ + return { + envKeyPrefix: FINDYMAIL_API_KEY_PREFIX, + apiKeyParam: 'apiKey', + byokProviderId: 'findymail', + pricing: { + type: 'custom', + getCost: (params, output) => { + const credits = getCredits(params, output) + return { cost: credits * FINDYMAIL_CREDIT_USD, metadata: { credits } } + }, + }, + rateLimit: { + mode: 'per_request', + requestsPerMinute: 60, + }, + } +} diff --git a/apps/sim/tools/findymail/lookup_technologies.ts b/apps/sim/tools/findymail/lookup_technologies.ts index b7fb2af8d1..e7360f3615 100644 --- a/apps/sim/tools/findymail/lookup_technologies.ts +++ b/apps/sim/tools/findymail/lookup_technologies.ts @@ -1,3 +1,4 @@ +import { findymailHosting } from '@/tools/findymail/hosting' import type { FindymailLookupTechnologiesParams, FindymailLookupTechnologiesResponse, @@ -15,6 +16,11 @@ export const lookupTechnologiesTool: ToolConfig< 'Get the technology stack for a company by domain. Optionally filter by technology names. 1 finder credit if technologies are found, free otherwise.', version: '1.0.0', + hosting: findymailHosting((_params, output) => { + // 1 finder credit when a technology stack is returned, free otherwise. + return Array.isArray(output.technologies) && output.technologies.length > 0 ? 1 : 0 + }), + params: { domain: { type: 'string', diff --git a/apps/sim/tools/findymail/reverse_email_lookup.ts b/apps/sim/tools/findymail/reverse_email_lookup.ts index 48a0c42ca1..447d112b69 100644 --- a/apps/sim/tools/findymail/reverse_email_lookup.ts +++ b/apps/sim/tools/findymail/reverse_email_lookup.ts @@ -1,3 +1,4 @@ +import { findymailHosting } from '@/tools/findymail/hosting' import type { FindymailReverseEmailLookupParams, FindymailReverseEmailLookupResponse, @@ -14,6 +15,13 @@ export const reverseEmailLookupTool: ToolConfig< 'Find a business profile from an email address. Uses 1 finder credit if a profile is found, 2 credits if returning full profile data.', version: '1.0.0', + hosting: findymailHosting((params, output) => { + const found = Boolean(output.email || output.linkedin_url || output.fullName) + if (!found) return 0 + // 1 credit for a match, 2 when full profile enrichment is requested. + return params.with_profile ? 2 : 1 + }), + params: { email: { type: 'string', diff --git a/apps/sim/tools/findymail/search_technologies.ts b/apps/sim/tools/findymail/search_technologies.ts index a4f7bcadcc..64d56b785a 100644 --- a/apps/sim/tools/findymail/search_technologies.ts +++ b/apps/sim/tools/findymail/search_technologies.ts @@ -1,3 +1,4 @@ +import { findymailHosting } from '@/tools/findymail/hosting' import type { FindymailSearchTechnologiesParams, FindymailSearchTechnologiesResponse, @@ -15,6 +16,11 @@ export const searchTechnologiesTool: ToolConfig< 'Search the technology catalog by name. Returns up to 25 technologies. Free endpoint, rate limited to 10 requests per minute.', version: '1.0.0', + hosting: findymailHosting(() => { + // Free catalog search — consumes no Findymail credits. + return 0 + }), + params: { q: { type: 'string', diff --git a/apps/sim/tools/findymail/verify_email.ts b/apps/sim/tools/findymail/verify_email.ts index c239e296cd..30463e1fe6 100644 --- a/apps/sim/tools/findymail/verify_email.ts +++ b/apps/sim/tools/findymail/verify_email.ts @@ -1,3 +1,4 @@ +import { findymailHosting } from '@/tools/findymail/hosting' import type { FindymailVerifyEmailParams, FindymailVerifyEmailResponse, @@ -11,6 +12,11 @@ export const verifyEmailTool: ToolConfig(() => { + // Each verification consumes one verifier credit, billed at the finder-credit rate. + return 1 + }), + params: { email: { type: 'string', diff --git a/apps/sim/tools/prospeo/bulk_enrich_company.ts b/apps/sim/tools/prospeo/bulk_enrich_company.ts index c73c6e70f1..f202392162 100644 --- a/apps/sim/tools/prospeo/bulk_enrich_company.ts +++ b/apps/sim/tools/prospeo/bulk_enrich_company.ts @@ -1,3 +1,4 @@ +import { prospeoHosting } from '@/tools/prospeo/hosting' import { extractProspeoError, type ProspeoBulkEnrichCompanyParams, @@ -15,6 +16,14 @@ export const bulkEnrichCompanyTool: ToolConfig< description: 'Enrich up to 50 company records at once.', version: '1.0.0', + hosting: prospeoHosting((_params, output) => { + // Prospeo reports the exact credits spent for the batch in total_cost. + if (typeof output.total_cost !== 'number') { + throw new Error('Prospeo bulk enrich company response missing total_cost') + } + return output.total_cost + }), + params: { apiKey: { type: 'string', diff --git a/apps/sim/tools/prospeo/bulk_enrich_person.ts b/apps/sim/tools/prospeo/bulk_enrich_person.ts index b6d095e952..e594e9b7cb 100644 --- a/apps/sim/tools/prospeo/bulk_enrich_person.ts +++ b/apps/sim/tools/prospeo/bulk_enrich_person.ts @@ -1,3 +1,4 @@ +import { prospeoHosting } from '@/tools/prospeo/hosting' import { extractProspeoError, type ProspeoBulkEnrichPersonParams, @@ -15,6 +16,14 @@ export const bulkEnrichPersonTool: ToolConfig< description: 'Enrich up to 50 person records at once.', version: '1.0.0', + hosting: prospeoHosting((_params, output) => { + // Prospeo reports the exact credits spent for the batch in total_cost. + if (typeof output.total_cost !== 'number') { + throw new Error('Prospeo bulk enrich person response missing total_cost') + } + return output.total_cost + }), + params: { apiKey: { type: 'string', diff --git a/apps/sim/tools/prospeo/enrich_company.ts b/apps/sim/tools/prospeo/enrich_company.ts index ca4e1ed9b9..7f0b141e3d 100644 --- a/apps/sim/tools/prospeo/enrich_company.ts +++ b/apps/sim/tools/prospeo/enrich_company.ts @@ -1,3 +1,4 @@ +import { prospeoHosting } from '@/tools/prospeo/hosting' import { extractProspeoError, type ProspeoEnrichCompanyParams, @@ -14,6 +15,12 @@ export const enrichCompanyTool: ToolConfig< description: 'Enrich a company with complete B2B data.', version: '1.0.0', + hosting: prospeoHosting((_params, output) => { + // 1 credit per company match; no charge on a no-match or repeat enrichment. + if (output.free_enrichment === true) return 0 + return output.company ? 1 : 0 + }), + params: { apiKey: { type: 'string', diff --git a/apps/sim/tools/prospeo/enrich_person.ts b/apps/sim/tools/prospeo/enrich_person.ts index 44c72a2600..e072dc6bbb 100644 --- a/apps/sim/tools/prospeo/enrich_person.ts +++ b/apps/sim/tools/prospeo/enrich_person.ts @@ -1,3 +1,4 @@ +import { prospeoHosting } from '@/tools/prospeo/hosting' import { extractProspeoError, type ProspeoEnrichPersonParams, @@ -12,6 +13,16 @@ export const enrichPersonTool: ToolConfig((_params, output) => { + // No charge on a no-match or a repeat enrichment. + if (output.free_enrichment === true) return 0 + const person = output.person as Record | null + if (!person) return 0 + // 10 credits when a mobile is revealed, otherwise 1 for the person match. + const mobile = person.mobile as { revealed?: boolean } | undefined + return mobile?.revealed ? 10 : 1 + }), + params: { apiKey: { type: 'string', diff --git a/apps/sim/tools/prospeo/hosting.ts b/apps/sim/tools/prospeo/hosting.ts new file mode 100644 index 0000000000..c5d97b9847 --- /dev/null +++ b/apps/sim/tools/prospeo/hosting.ts @@ -0,0 +1,43 @@ +import type { ToolHostingConfig } from '@/tools/types' + +/** + * Env var prefix for Prospeo hosted keys. Provide keys as + * `PROSPEO_API_KEY_COUNT` plus `PROSPEO_API_KEY_1..N`. + */ +export const PROSPEO_API_KEY_PREFIX = 'PROSPEO_API_KEY' + +/** + * Dollar cost of a single Prospeo credit. + * + * Prospeo charges per match: 1 credit per person/company match, 10 credits when + * a mobile is revealed, and never on a no-match or a repeat enrichment. Based on + * the $39/month Starter plan (1,000 credits ≈ $0.039/credit) — https://prospeo.io/pricing. + */ +export const PROSPEO_CREDIT_USD = 0.039 + +/** + * Build a Prospeo `hosting` config. `getCredits` returns the number of Prospeo + * credits the call consumed, derived from the tool's output (prefer the + * API-reported `total_cost` for bulk endpoints; otherwise compute from the + * `free`/`free_enrichment` flag and the match). + */ +export function prospeoHosting

( + getCredits: (params: P, output: Record) => number +): ToolHostingConfig

{ + return { + envKeyPrefix: PROSPEO_API_KEY_PREFIX, + apiKeyParam: 'apiKey', + byokProviderId: 'prospeo', + pricing: { + type: 'custom', + getCost: (params, output) => { + const credits = getCredits(params, output) + return { cost: credits * PROSPEO_CREDIT_USD, metadata: { credits } } + }, + }, + rateLimit: { + mode: 'per_request', + requestsPerMinute: 60, + }, + } +} diff --git a/apps/sim/tools/prospeo/search_company.ts b/apps/sim/tools/prospeo/search_company.ts index f4e8a1c0b5..e16ecaf83c 100644 --- a/apps/sim/tools/prospeo/search_company.ts +++ b/apps/sim/tools/prospeo/search_company.ts @@ -1,3 +1,4 @@ +import { prospeoHosting } from '@/tools/prospeo/hosting' import { extractProspeoError, PROSPEO_PAGINATION_OUTPUT, @@ -16,6 +17,13 @@ export const searchCompanyTool: ToolConfig< description: 'Search for companies using 20+ filters to build account lists.', version: '1.0.0', + hosting: prospeoHosting((_params, output) => { + // 1 credit per page that returns at least one result; free on 30-day dedup. + if (output.free === true) return 0 + const results = output.results + return Array.isArray(results) && results.length > 0 ? 1 : 0 + }), + params: { apiKey: { type: 'string', diff --git a/apps/sim/tools/prospeo/search_person.ts b/apps/sim/tools/prospeo/search_person.ts index c8415ceec9..883b11e73d 100644 --- a/apps/sim/tools/prospeo/search_person.ts +++ b/apps/sim/tools/prospeo/search_person.ts @@ -1,3 +1,4 @@ +import { prospeoHosting } from '@/tools/prospeo/hosting' import { extractProspeoError, PROSPEO_PAGINATION_OUTPUT, @@ -14,6 +15,13 @@ export const searchPersonTool: ToolConfig((_params, output) => { + // 1 credit per page that returns at least one result; free on 30-day dedup. + if (output.free === true) return 0 + const results = output.results + return Array.isArray(results) && results.length > 0 ? 1 : 0 + }), + params: { apiKey: { type: 'string', diff --git a/apps/sim/tools/registry.ts b/apps/sim/tools/registry.ts index 3070576fa4..d6558b4942 100644 --- a/apps/sim/tools/registry.ts +++ b/apps/sim/tools/registry.ts @@ -3053,9 +3053,8 @@ import { import { wizaCompanyEnrichmentTool, wizaGetCreditsTool, - wizaGetIndividualRevealTool, + wizaIndividualRevealTool, wizaProspectSearchTool, - wizaStartIndividualRevealTool, } from '@/tools/wiza' import { wordpressCreateCategoryTool, @@ -5436,9 +5435,8 @@ export const tools: Record = { wikipedia_random: wikipediaRandomPageTool, wiza_company_enrichment: wizaCompanyEnrichmentTool, wiza_get_credits: wizaGetCreditsTool, - wiza_get_individual_reveal: wizaGetIndividualRevealTool, + wiza_individual_reveal: wizaIndividualRevealTool, wiza_prospect_search: wizaProspectSearchTool, - wiza_start_individual_reveal: wizaStartIndividualRevealTool, wordpress_create_post: wordpressCreatePostTool, wordpress_update_post: wordpressUpdatePostTool, wordpress_delete_post: wordpressDeletePostTool, diff --git a/apps/sim/tools/types.ts b/apps/sim/tools/types.ts index b89ca98cd7..24501cb6d9 100644 --- a/apps/sim/tools/types.ts +++ b/apps/sim/tools/types.ts @@ -21,6 +21,9 @@ export type BYOKProviderId = | 'cohere' | 'hunter' | 'peopledatalabs' + | 'findymail' + | 'prospeo' + | 'wiza' export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'HEAD' @@ -309,7 +312,7 @@ export type ToolHostingPricing

> = PerRequestPricing * Adding more keys only requires updating the count and adding the new env var — * no code changes needed. */ -interface ToolHostingConfig

> { +export interface ToolHostingConfig

> { /** Optional predicate for tools where hosted keys only apply to some parameter combinations. */ enabled?: (params: P) => boolean /** diff --git a/apps/sim/tools/wiza/company_enrichment.ts b/apps/sim/tools/wiza/company_enrichment.ts index 70bfdfec5b..39089404f1 100644 --- a/apps/sim/tools/wiza/company_enrichment.ts +++ b/apps/sim/tools/wiza/company_enrichment.ts @@ -1,4 +1,5 @@ import type { ToolConfig } from '@/tools/types' +import { wizaHosting } from '@/tools/wiza/hosting' import type { WizaCompanyEnrichmentParams, WizaCompanyEnrichmentResponse } from '@/tools/wiza/types' export const wizaCompanyEnrichmentTool: ToolConfig< @@ -11,6 +12,11 @@ export const wizaCompanyEnrichmentTool: ToolConfig< 'Enrich a company by name, domain, LinkedIn ID, or LinkedIn slug with detailed firmographic data', version: '1.0.0', + hosting: wizaHosting((_params, output) => { + // 2 API credits per successful company match; no charge on a no-match. + return output.company_name || output.company_domain || output.domain ? 2 : 0 + }), + params: { apiKey: { type: 'string', diff --git a/apps/sim/tools/wiza/get_individual_reveal.ts b/apps/sim/tools/wiza/get_individual_reveal.ts deleted file mode 100644 index 7c3d4ec543..0000000000 --- a/apps/sim/tools/wiza/get_individual_reveal.ts +++ /dev/null @@ -1,176 +0,0 @@ -import type { ToolConfig } from '@/tools/types' -import type { - WizaGetIndividualRevealParams, - WizaGetIndividualRevealResponse, -} from '@/tools/wiza/types' - -export const wizaGetIndividualRevealTool: ToolConfig< - WizaGetIndividualRevealParams, - WizaGetIndividualRevealResponse -> = { - id: 'wiza_get_individual_reveal', - name: 'Wiza Get Individual Reveal', - description: 'Retrieve the status and enriched data for an individual reveal by ID', - version: '1.0.0', - - params: { - apiKey: { - type: 'string', - required: true, - visibility: 'user-only', - description: 'Wiza API key', - }, - id: { - type: 'string', - required: true, - visibility: 'user-or-llm', - description: 'Individual reveal ID returned from Start Individual Reveal', - }, - }, - - request: { - url: (params: WizaGetIndividualRevealParams) => - `https://wiza.co/api/individual_reveals/${encodeURIComponent(String(params.id).trim())}`, - method: 'GET', - headers: (params: WizaGetIndividualRevealParams) => ({ - Authorization: `Bearer ${params.apiKey}`, - 'Content-Type': 'application/json', - }), - }, - - transformResponse: async (response: Response) => { - if (!response.ok) { - const errorText = await response.text() - throw new Error(`Wiza API error: ${response.status} - ${errorText}`) - } - - const json = await response.json() - const d = json.data ?? {} - const emails = Array.isArray(d.emails) ? d.emails : [] - const phones = Array.isArray(d.phones) ? d.phones : [] - - return { - success: true, - output: { - id: d.id ?? null, - status: d.status ?? null, - is_complete: d.is_complete ?? null, - name: d.name ?? null, - company: d.company ?? null, - enrichment_level: d.enrichment_level ?? null, - linkedin_profile_url: d.linkedin_profile_url ?? null, - title: d.title ?? null, - location: d.location ?? null, - email: d.email ?? null, - email_type: d.email_type ?? null, - email_status: d.email_status ?? null, - emails: emails.map((e: Record) => ({ - email: (e.email as string) ?? null, - email_type: (e.email_type as string) ?? null, - email_status: (e.email_status as string) ?? null, - })), - mobile_phone: d.mobile_phone ?? null, - phone_number: d.phone_number ?? null, - phone_status: d.phone_status ?? null, - phones: phones.map((p: Record) => ({ - number: (p.number as string) ?? null, - pretty_number: (p.pretty_number as string) ?? null, - type: (p.type as string) ?? null, - })), - company_size: d.company_size ?? null, - company_size_range: d.company_size_range ?? null, - company_type: d.company_type ?? null, - company_domain: d.company_domain ?? null, - company_locality: d.company_locality ?? null, - company_region: d.company_region ?? null, - company_country: d.company_country ?? null, - company_street: d.company_street ?? null, - company_postal_code: d.company_postal_code ?? null, - company_founded: d.company_founded ?? null, - company_funding: d.company_funding ?? null, - company_revenue: d.company_revenue ?? null, - company_industry: d.company_industry ?? null, - company_subindustry: d.company_subindustry ?? null, - company_linkedin: d.company_linkedin ?? null, - company_location: d.company_location ?? null, - company_description: d.company_description ?? null, - credits: d.credits ?? null, - }, - } - }, - - outputs: { - id: { type: 'number', description: 'Reveal ID', optional: true }, - status: { - type: 'string', - description: 'queued | resolving | finished | failed', - optional: true, - }, - is_complete: { - type: 'boolean', - description: 'Whether the reveal has completed', - optional: true, - }, - name: { type: 'string', description: 'Full name', optional: true }, - company: { type: 'string', description: 'Company name', optional: true }, - enrichment_level: { type: 'string', description: 'Enrichment level used', optional: true }, - linkedin_profile_url: { type: 'string', description: 'LinkedIn URL', optional: true }, - title: { type: 'string', description: 'Job title', optional: true }, - location: { type: 'string', description: 'Location', optional: true }, - email: { type: 'string', description: 'Primary email', optional: true }, - email_type: { type: 'string', description: 'Email type', optional: true }, - email_status: { type: 'string', description: 'valid | risky | unfound', optional: true }, - emails: { - type: 'array', - description: 'All emails found', - optional: true, - items: { - type: 'object', - properties: { - email: { type: 'string' }, - email_type: { type: 'string' }, - email_status: { type: 'string' }, - }, - }, - }, - mobile_phone: { type: 'string', description: 'Mobile phone', optional: true }, - phone_number: { type: 'string', description: 'Direct/office phone', optional: true }, - phone_status: { type: 'string', description: 'found | unfound', optional: true }, - phones: { - type: 'array', - description: 'All phones found', - optional: true, - items: { - type: 'object', - properties: { - number: { type: 'string' }, - pretty_number: { type: 'string' }, - type: { type: 'string' }, - }, - }, - }, - company_size: { type: 'number', description: 'Employee count', optional: true }, - company_size_range: { type: 'string', description: 'Headcount range', optional: true }, - company_type: { type: 'string', description: 'Company type', optional: true }, - company_domain: { type: 'string', description: 'Company domain', optional: true }, - company_locality: { type: 'string', description: 'City', optional: true }, - company_region: { type: 'string', description: 'State/region', optional: true }, - company_country: { type: 'string', description: 'Country', optional: true }, - company_street: { type: 'string', description: 'Street', optional: true }, - company_postal_code: { type: 'string', description: 'Postal code', optional: true }, - company_founded: { type: 'number', description: 'Year founded', optional: true }, - company_funding: { type: 'string', description: 'Funding total', optional: true }, - company_revenue: { type: 'string', description: 'Revenue', optional: true }, - company_industry: { type: 'string', description: 'Industry', optional: true }, - company_subindustry: { type: 'string', description: 'Subindustry', optional: true }, - company_linkedin: { type: 'string', description: 'Company LinkedIn URL', optional: true }, - company_location: { type: 'string', description: 'Full company location', optional: true }, - company_description: { type: 'string', description: 'Company description', optional: true }, - credits: { - type: 'json', - description: - 'Credits deducted for this reveal (api_credits: { total, email_credits, phone_credits, scrape_credits })', - optional: true, - }, - }, -} diff --git a/apps/sim/tools/wiza/hosting.ts b/apps/sim/tools/wiza/hosting.ts new file mode 100644 index 0000000000..43fe367ae3 --- /dev/null +++ b/apps/sim/tools/wiza/hosting.ts @@ -0,0 +1,42 @@ +import type { ToolHostingConfig } from '@/tools/types' + +/** + * Env var prefix for Wiza hosted keys. Provide keys as `WIZA_API_KEY_COUNT` + * plus `WIZA_API_KEY_1..N`. + */ +export const WIZA_API_KEY_PREFIX = 'WIZA_API_KEY' + +/** + * Dollar cost of a single Wiza API credit. + * + * Wiza meters API usage in credits at a documented $0.025/credit (2,000-credit + * minimum) — https://help.wiza.co/en/articles/13551713-how-to-purchase-api-credits. + * Credits are deducted only when data is successfully returned: 2 credits per + * valid email, 5 credits per phone, 2 credits per company enrichment. + */ +export const WIZA_CREDIT_USD = 0.025 + +/** + * Build a Wiza `hosting` config. `getCredits` returns the number of Wiza API + * credits the call consumed, derived from the tool's output. + */ +export function wizaHosting

( + getCredits: (params: P, output: Record) => number +): ToolHostingConfig

{ + return { + envKeyPrefix: WIZA_API_KEY_PREFIX, + apiKeyParam: 'apiKey', + byokProviderId: 'wiza', + pricing: { + type: 'custom', + getCost: (params, output) => { + const credits = getCredits(params, output) + return { cost: credits * WIZA_CREDIT_USD, metadata: { credits } } + }, + }, + rateLimit: { + mode: 'per_request', + requestsPerMinute: 60, + }, + } +} diff --git a/apps/sim/tools/wiza/index.ts b/apps/sim/tools/wiza/index.ts index 21df6dec38..289503b4da 100644 --- a/apps/sim/tools/wiza/index.ts +++ b/apps/sim/tools/wiza/index.ts @@ -1,6 +1,5 @@ export { wizaCompanyEnrichmentTool } from './company_enrichment' export { wizaGetCreditsTool } from './get_credits' -export { wizaGetIndividualRevealTool } from './get_individual_reveal' +export { wizaIndividualRevealTool } from './individual_reveal' export { wizaProspectSearchTool } from './prospect_search' -export { wizaStartIndividualRevealTool } from './start_individual_reveal' export type * from './types' diff --git a/apps/sim/tools/wiza/individual_reveal.ts b/apps/sim/tools/wiza/individual_reveal.ts new file mode 100644 index 0000000000..291d1791f6 --- /dev/null +++ b/apps/sim/tools/wiza/individual_reveal.ts @@ -0,0 +1,330 @@ +import { sleep } from '@sim/utils/helpers' +import type { ToolConfig } from '@/tools/types' +import { wizaHosting } from '@/tools/wiza/hosting' +import type { + WizaIndividualRevealData, + WizaIndividualRevealParams, + WizaIndividualRevealResponse, +} from '@/tools/wiza/types' + +const POLL_INTERVAL_MS = 2000 +const MAX_POLL_TIME_MS = 120000 +/** Tolerate brief Wiza outages while polling before giving up on an already-started reveal. */ +const MAX_CONSECUTIVE_POLL_ERRORS = 3 + +/** Whether a reveal payload has reached a terminal state and no longer needs polling. */ +function isTerminalReveal(d: { status?: string | null; is_complete?: boolean | null }): boolean { + return d.status === 'finished' || d.status === 'failed' || d.is_complete === true +} + +/** Map a Wiza individual-reveal payload (`data` object) to the tool output shape. */ +function mapRevealData(d: Record): WizaIndividualRevealData { + const emails = Array.isArray(d.emails) ? (d.emails as Record[]) : [] + const phones = Array.isArray(d.phones) ? (d.phones as Record[]) : [] + return { + id: (d.id as number) ?? null, + status: (d.status as string) ?? null, + is_complete: (d.is_complete as boolean) ?? null, + name: (d.name as string) ?? null, + company: (d.company as string) ?? null, + enrichment_level: (d.enrichment_level as string) ?? null, + linkedin_profile_url: (d.linkedin_profile_url as string) ?? null, + title: (d.title as string) ?? null, + location: (d.location as string) ?? null, + email: (d.email as string) ?? null, + email_type: (d.email_type as string) ?? null, + email_status: (d.email_status as string) ?? null, + emails: emails.map((e) => ({ + email: (e.email as string) ?? null, + email_type: (e.email_type as string) ?? null, + email_status: (e.email_status as string) ?? null, + })), + mobile_phone: (d.mobile_phone as string) ?? null, + phone_number: (d.phone_number as string) ?? null, + phone_status: (d.phone_status as string) ?? null, + phones: phones.map((p) => ({ + number: (p.number as string) ?? null, + pretty_number: (p.pretty_number as string) ?? null, + type: (p.type as string) ?? null, + })), + company_size: (d.company_size as number) ?? null, + company_size_range: (d.company_size_range as string) ?? null, + company_type: (d.company_type as string) ?? null, + company_domain: (d.company_domain as string) ?? null, + company_locality: (d.company_locality as string) ?? null, + company_region: (d.company_region as string) ?? null, + company_country: (d.company_country as string) ?? null, + company_street: (d.company_street as string) ?? null, + company_postal_code: (d.company_postal_code as string) ?? null, + company_founded: (d.company_founded as number) ?? null, + company_funding: (d.company_funding as string) ?? null, + company_revenue: (d.company_revenue as string) ?? null, + company_industry: (d.company_industry as string) ?? null, + company_subindustry: (d.company_subindustry as string) ?? null, + company_linkedin: (d.company_linkedin as string) ?? null, + company_location: (d.company_location as string) ?? null, + company_description: (d.company_description as string) ?? null, + credits: (d.credits as Record) ?? null, + } +} + +export const wizaIndividualRevealTool: ToolConfig< + WizaIndividualRevealParams, + WizaIndividualRevealResponse +> = { + id: 'wiza_individual_reveal', + name: 'Wiza Individual Reveal', + description: + 'Reveal a contact via LinkedIn URL, name + company/domain, or email. Starts the reveal and polls until it resolves. Uses 2 credits per valid email and 5 credits per phone, charged only on success.', + version: '1.0.0', + + hosting: wizaHosting((_params, output) => { + let credits = 0 + const emails = Array.isArray(output.emails) + ? (output.emails as { email_status?: string }[]) + : [] + const emailValid = + output.email_status === 'valid' || emails.some((e) => e.email_status === 'valid') + // 2 credits when at least one valid email is returned. + if (emailValid) credits += 2 + const phones = Array.isArray(output.phones) ? output.phones : [] + const phoneFound = Boolean(output.mobile_phone || output.phone_number || phones.length > 0) + // 5 credits when at least one phone is returned. + if (phoneFound) credits += 5 + return credits + }), + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Wiza API key', + }, + enrichment_level: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Enrichment depth: none, partial, phone, or full', + }, + profile_url: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'LinkedIn profile URL (e.g., https://linkedin.com/in/johndoe)', + }, + full_name: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Full name (used with company or domain)', + }, + company: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Company name (used with full_name)', + }, + domain: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Company domain (used with full_name)', + }, + email: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Email address (use alone or with other identifiers)', + }, + accept_work: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Whether to accept work emails (email_options)', + }, + accept_personal: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Whether to accept personal emails (email_options)', + }, + }, + + request: { + url: 'https://wiza.co/api/individual_reveals', + method: 'POST', + headers: (params: WizaIndividualRevealParams) => ({ + Authorization: `Bearer ${params.apiKey}`, + 'Content-Type': 'application/json', + }), + body: (params: WizaIndividualRevealParams) => { + const individual: Record = {} + if (params.profile_url) individual.profile_url = params.profile_url + if (params.full_name) individual.full_name = params.full_name + if (params.company) individual.company = params.company + if (params.domain) individual.domain = params.domain + if (params.email) individual.email = params.email + + const body: Record = { + individual_reveal: individual, + enrichment_level: params.enrichment_level, + } + + if (params.accept_work !== undefined || params.accept_personal !== undefined) { + const emailOptions: Record = {} + if (params.accept_work !== undefined) emailOptions.accept_work = params.accept_work + if (params.accept_personal !== undefined) { + emailOptions.accept_personal = params.accept_personal + } + body.email_options = emailOptions + } + + return body + }, + }, + + transformResponse: async (response: Response) => { + if (!response.ok) { + const errorText = await response.text() + throw new Error(`Wiza API error: ${response.status} - ${errorText}`) + } + const json = await response.json() + return { + success: true, + output: mapRevealData(json.data ?? {}), + } + }, + + postProcess: async (result, params) => { + if (!result.success) return result + + // Wiza can resolve synchronously (e.g. a cache hit) — the initial POST payload is + // already mapped, so skip polling when it is terminal. + if (isTerminalReveal(result.output)) { + return { success: result.output.status !== 'failed', output: result.output } + } + + const revealId = result.output.id + if (revealId == null) { + // Return an explicit failure rather than throwing: a thrown error here is swallowed + // by the executor and masked as the queued (incomplete) success result. + return { + success: false, + error: 'Wiza individual reveal did not return an id', + output: result.output, + } + } + + let elapsedTime = 0 + let consecutiveErrors = 0 + while (elapsedTime < MAX_POLL_TIME_MS) { + await sleep(POLL_INTERVAL_MS) + elapsedTime += POLL_INTERVAL_MS + + const statusResponse = await fetch( + `https://wiza.co/api/individual_reveals/${encodeURIComponent(String(revealId))}`, + { + headers: { + Authorization: `Bearer ${params.apiKey}`, + 'Content-Type': 'application/json', + }, + } + ) + + if (!statusResponse.ok) { + // The reveal is already started (and billed by Wiza), so tolerate brief outages and + // retry rather than aborting the whole window on a single transient 5xx/429. + consecutiveErrors += 1 + if (consecutiveErrors >= MAX_CONSECUTIVE_POLL_ERRORS) { + const errorText = await statusResponse.text().catch(() => '') + return { + success: false, + error: `Wiza API error: ${statusResponse.status} - ${errorText}`, + output: result.output, + } + } + continue + } + consecutiveErrors = 0 + + const json = await statusResponse.json() + const data = json.data ?? {} + + if (isTerminalReveal(data)) { + return { + success: data.status !== 'failed', + output: mapRevealData(data), + } + } + } + + return { + success: false, + error: 'Wiza individual reveal did not complete within the polling window', + output: result.output, + } + }, + + outputs: { + id: { type: 'number', description: 'Reveal ID' }, + status: { type: 'string', description: 'queued | resolving | finished | failed' }, + is_complete: { type: 'boolean', description: 'Whether the reveal has completed' }, + name: { type: 'string', description: 'Full name', optional: true }, + company: { type: 'string', description: 'Company name', optional: true }, + enrichment_level: { type: 'string', description: 'Enrichment level used', optional: true }, + linkedin_profile_url: { type: 'string', description: 'LinkedIn URL', optional: true }, + title: { type: 'string', description: 'Job title', optional: true }, + location: { type: 'string', description: 'Location', optional: true }, + email: { type: 'string', description: 'Primary email', optional: true }, + email_type: { type: 'string', description: 'Email type', optional: true }, + email_status: { type: 'string', description: 'valid | risky | unfound', optional: true }, + emails: { + type: 'array', + description: 'All emails found', + optional: true, + items: { + type: 'object', + properties: { + email: { type: 'string' }, + email_type: { type: 'string' }, + email_status: { type: 'string' }, + }, + }, + }, + mobile_phone: { type: 'string', description: 'Mobile phone', optional: true }, + phone_number: { type: 'string', description: 'Direct/office phone', optional: true }, + phone_status: { type: 'string', description: 'found | unfound', optional: true }, + phones: { + type: 'array', + description: 'All phones found', + optional: true, + items: { + type: 'object', + properties: { + number: { type: 'string' }, + pretty_number: { type: 'string' }, + type: { type: 'string' }, + }, + }, + }, + company_size: { type: 'number', description: 'Employee count', optional: true }, + company_size_range: { type: 'string', description: 'Headcount range', optional: true }, + company_type: { type: 'string', description: 'Company type', optional: true }, + company_domain: { type: 'string', description: 'Company domain', optional: true }, + company_locality: { type: 'string', description: 'City', optional: true }, + company_region: { type: 'string', description: 'State/region', optional: true }, + company_country: { type: 'string', description: 'Country', optional: true }, + company_street: { type: 'string', description: 'Street', optional: true }, + company_postal_code: { type: 'string', description: 'Postal code', optional: true }, + company_founded: { type: 'number', description: 'Year founded', optional: true }, + company_funding: { type: 'string', description: 'Funding total', optional: true }, + company_revenue: { type: 'string', description: 'Revenue', optional: true }, + company_industry: { type: 'string', description: 'Industry', optional: true }, + company_subindustry: { type: 'string', description: 'Subindustry', optional: true }, + company_linkedin: { type: 'string', description: 'Company LinkedIn URL', optional: true }, + company_location: { type: 'string', description: 'Full company location', optional: true }, + company_description: { type: 'string', description: 'Company description', optional: true }, + credits: { type: 'json', description: 'Credits consumed by the reveal', optional: true }, + }, +} diff --git a/apps/sim/tools/wiza/prospect_search.ts b/apps/sim/tools/wiza/prospect_search.ts index c507f0cbb6..fb460b39b6 100644 --- a/apps/sim/tools/wiza/prospect_search.ts +++ b/apps/sim/tools/wiza/prospect_search.ts @@ -1,4 +1,5 @@ import type { ToolConfig } from '@/tools/types' +import { wizaHosting } from '@/tools/wiza/hosting' import type { WizaProspectSearchParams, WizaProspectSearchResponse } from '@/tools/wiza/types' export const wizaProspectSearchTool: ToolConfig< @@ -10,6 +11,12 @@ export const wizaProspectSearchTool: ToolConfig< description: "Search Wiza's database of prospects using person, company, and financial filters", version: '1.0.0', + hosting: wizaHosting(() => { + // Prospect search returns profiles without contact data and consumes no credits; + // Wiza charges only on reveal/enrichment. + return 0 + }), + params: { apiKey: { type: 'string', diff --git a/apps/sim/tools/wiza/start_individual_reveal.ts b/apps/sim/tools/wiza/start_individual_reveal.ts deleted file mode 100644 index 902c51f872..0000000000 --- a/apps/sim/tools/wiza/start_individual_reveal.ts +++ /dev/null @@ -1,151 +0,0 @@ -import type { ToolConfig } from '@/tools/types' -import type { - WizaStartIndividualRevealParams, - WizaStartIndividualRevealResponse, -} from '@/tools/wiza/types' - -export const wizaStartIndividualRevealTool: ToolConfig< - WizaStartIndividualRevealParams, - WizaStartIndividualRevealResponse -> = { - id: 'wiza_start_individual_reveal', - name: 'Wiza Start Individual Reveal', - description: - 'Start an individual reveal to enrich a contact via LinkedIn URL, name+company, or email', - version: '1.0.0', - - params: { - apiKey: { - type: 'string', - required: true, - visibility: 'user-only', - description: 'Wiza API key', - }, - enrichment_level: { - type: 'string', - required: true, - visibility: 'user-or-llm', - description: 'Enrichment depth: none, partial, phone, or full', - }, - profile_url: { - type: 'string', - required: false, - visibility: 'user-or-llm', - description: 'LinkedIn profile URL (e.g., https://linkedin.com/in/johndoe)', - }, - full_name: { - type: 'string', - required: false, - visibility: 'user-or-llm', - description: 'Full name (used with company or domain)', - }, - company: { - type: 'string', - required: false, - visibility: 'user-or-llm', - description: 'Company name (used with full_name)', - }, - domain: { - type: 'string', - required: false, - visibility: 'user-or-llm', - description: 'Company domain (used with full_name)', - }, - email: { - type: 'string', - required: false, - visibility: 'user-or-llm', - description: 'Email address (use alone or with other identifiers)', - }, - accept_work: { - type: 'boolean', - required: false, - visibility: 'user-or-llm', - description: 'Whether to accept work emails (email_options)', - }, - accept_personal: { - type: 'boolean', - required: false, - visibility: 'user-or-llm', - description: 'Whether to accept personal emails (email_options)', - }, - callback_url: { - type: 'string', - required: false, - visibility: 'user-or-llm', - description: 'Optional URL to receive a callback with the reveal update', - }, - }, - - request: { - url: 'https://wiza.co/api/individual_reveals', - method: 'POST', - headers: (params: WizaStartIndividualRevealParams) => ({ - Authorization: `Bearer ${params.apiKey}`, - 'Content-Type': 'application/json', - }), - body: (params: WizaStartIndividualRevealParams) => { - const individual: Record = {} - if (params.profile_url) individual.profile_url = params.profile_url - if (params.full_name) individual.full_name = params.full_name - if (params.company) individual.company = params.company - if (params.domain) individual.domain = params.domain - if (params.email) individual.email = params.email - - const body: Record = { - individual_reveal: individual, - enrichment_level: params.enrichment_level, - } - - if (params.accept_work !== undefined || params.accept_personal !== undefined) { - const emailOptions: Record = {} - if (params.accept_work !== undefined) emailOptions.accept_work = params.accept_work - if (params.accept_personal !== undefined) { - emailOptions.accept_personal = params.accept_personal - } - body.email_options = emailOptions - } - - if (params.callback_url) body.callback_url = params.callback_url - - return body - }, - }, - - transformResponse: async (response: Response) => { - if (!response.ok) { - const errorText = await response.text() - throw new Error(`Wiza API error: ${response.status} - ${errorText}`) - } - - const json = await response.json() - const d = json.data ?? {} - - return { - success: true, - output: { - id: d.id ?? null, - status: d.status ?? null, - is_complete: d.is_complete ?? null, - }, - } - }, - - outputs: { - id: { - type: 'number', - description: 'Individual reveal ID (use with Get Individual Reveal)', - optional: true, - }, - status: { - type: 'string', - description: 'Reveal status: queued, resolving, finished, or failed', - optional: true, - }, - is_complete: { - type: 'boolean', - description: 'Whether the reveal has completed', - optional: true, - }, - }, -} diff --git a/apps/sim/tools/wiza/types.ts b/apps/sim/tools/wiza/types.ts index 00db0a2356..603a3fa933 100644 --- a/apps/sim/tools/wiza/types.ts +++ b/apps/sim/tools/wiza/types.ts @@ -105,7 +105,7 @@ export interface WizaCompanyEnrichmentResponse extends ToolResponse { } } -export interface WizaStartIndividualRevealParams { +export interface WizaIndividualRevealParams { apiKey: string enrichment_level: 'none' | 'partial' | 'phone' | 'full' profile_url?: string @@ -115,10 +115,9 @@ export interface WizaStartIndividualRevealParams { email?: string accept_work?: boolean accept_personal?: boolean - callback_url?: string } -interface WizaIndividualRevealData { +export interface WizaIndividualRevealData { id: number | null status: string | null is_complete: boolean | null @@ -164,20 +163,7 @@ interface WizaIndividualRevealData { credits: Record | null } -export interface WizaStartIndividualRevealResponse extends ToolResponse { - output: { - id: number | null - status: string | null - is_complete: boolean | null - } -} - -export interface WizaGetIndividualRevealParams { - apiKey: string - id: string -} - -export interface WizaGetIndividualRevealResponse extends ToolResponse { +export interface WizaIndividualRevealResponse extends ToolResponse { output: WizaIndividualRevealData } @@ -185,5 +171,4 @@ export type WizaResponse = | WizaGetCreditsResponse | WizaProspectSearchResponse | WizaCompanyEnrichmentResponse - | WizaStartIndividualRevealResponse - | WizaGetIndividualRevealResponse + | WizaIndividualRevealResponse From 925dd871e82cc2be4e62a4a6d35668853cd1d564 Mon Sep 17 00:00:00 2001 From: Waleed Date: Fri, 29 May 2026 15:56:11 -0700 Subject: [PATCH 05/29] fix(tables): reduce column header chevron size and fix sidebar shadow bleed (#4800) --- .../column-config-sidebar/column-config-sidebar.tsx | 4 ++-- .../components/enrichments-sidebar/enrichments-sidebar.tsx | 4 ++-- .../components/table-grid/headers/column-header-menu.tsx | 2 +- .../components/workflow-sidebar/workflow-sidebar.tsx | 4 ++-- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-config-sidebar/column-config-sidebar.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-config-sidebar/column-config-sidebar.tsx index 4905edb303..d7651bd3c6 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-config-sidebar/column-config-sidebar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-config-sidebar/column-config-sidebar.tsx @@ -53,8 +53,8 @@ export function ColumnConfigSidebar(props: ColumnConfigSidebarProps) { role='dialog' aria-label='Configure column' className={cn( - 'absolute top-0 right-0 bottom-0 z-[var(--z-modal)] flex w-[400px] flex-col overflow-hidden border-[var(--border)] border-l bg-[var(--bg)] shadow-overlay transition-transform duration-200 ease-out', - open ? 'translate-x-0' : 'translate-x-full' + 'absolute top-0 right-0 bottom-0 z-[var(--z-modal)] flex w-[400px] flex-col overflow-hidden border-[var(--border)] border-l bg-[var(--bg)] transition-transform duration-200 ease-out', + open ? 'translate-x-0 shadow-overlay' : 'translate-x-full' )} > {props.config && ( diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/enrichments-sidebar/enrichments-sidebar.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/enrichments-sidebar/enrichments-sidebar.tsx index 3594d0ca02..b77fc386db 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/enrichments-sidebar/enrichments-sidebar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/enrichments-sidebar/enrichments-sidebar.tsx @@ -33,8 +33,8 @@ export function EnrichmentsSidebar({ open, ...rest }: EnrichmentsSidebarProps) { role='dialog' aria-label='Enrichments' className={cn( - 'absolute top-0 right-0 bottom-0 z-[var(--z-modal)] flex w-[400px] flex-col overflow-hidden border-[var(--border)] border-l bg-[var(--bg)] shadow-overlay transition-transform duration-200 ease-out', - open ? 'translate-x-0' : 'translate-x-full' + 'absolute top-0 right-0 bottom-0 z-[var(--z-modal)] flex w-[400px] flex-col overflow-hidden border-[var(--border)] border-l bg-[var(--bg)] transition-transform duration-200 ease-out', + open ? 'translate-x-0 shadow-overlay' : 'translate-x-full' )} > {open && } diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/column-header-menu.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/column-header-menu.tsx index 7a76d6ee9b..463927819f 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/column-header-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/column-header-menu.tsx @@ -317,7 +317,7 @@ export const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({ draggable={false} aria-label='Column options' > - + {props.config && ( From e1e773f4878617b34c1360ad92860c0314779a94 Mon Sep 17 00:00:00 2001 From: Waleed Date: Fri, 29 May 2026 16:33:30 -0700 Subject: [PATCH 06/29] feat(slack): add install + privacy section to integration landing page (#4799) * feat(slack): add install + privacy section to integration landing page Adds a hand-authored, slug-keyed landing-content module (separate from the generated integrations.json so it survives regeneration) and renders an install walkthrough + privacy-policy link on integration pages when present. Also refreshes generated docs (data-enrichment entry, icon mappings, tool mdx). * fix(landing): render privacy section independently, align CTA analytics label * docs(landing): clarify the Slack install button is behind sign-in * refactor(landing): bake integration landing content into generated json via docs-gen Moves landing content (install walkthrough + privacy) out of a render-time augment and into the generation pipeline: generate-docs reads the pure-data content map and writes landingContent into integrations.json, so the page reads a single source (integration.landingContent). Canonical types live in integrations/data/types.ts. --- apps/docs/components/icons.tsx | 18 ++++- apps/docs/components/ui/icon-mapping.ts | 5 +- apps/docs/content/docs/en/tools/apollo.mdx | 7 +- .../docs/content/docs/en/tools/enrichment.mdx | 13 +--- apps/docs/content/docs/en/tools/resend.mdx | 27 +++++-- apps/docs/content/docs/en/tools/wiza.mdx | 2 +- apps/docs/content/docs/en/tools/zoominfo.mdx | 4 +- .../integrations/(shell)/[slug]/page.tsx | 73 +++++++++++++++++++ .../integrations/data/icon-mapping.ts | 5 +- .../integrations/data/integrations.json | 50 ++++++++++++- .../integrations/data/landing-content.ts | 41 +++++++++++ .../app/(landing)/integrations/data/types.ts | 24 ++++++ scripts/generate-docs.ts | 17 ++++- 13 files changed, 254 insertions(+), 32 deletions(-) create mode 100644 apps/sim/app/(landing)/integrations/data/landing-content.ts diff --git a/apps/docs/components/icons.tsx b/apps/docs/components/icons.tsx index 765920716b..7985328c08 100644 --- a/apps/docs/components/icons.tsx +++ b/apps/docs/components/icons.tsx @@ -1,6 +1,22 @@ import type { SVGProps } from 'react' import { useId } from 'react' +export function EnrichmentIcon(props: SVGProps) { + return ( + + + + + + ) +} + export function AgentMailIcon(props: SVGProps) { return ( @@ -4731,7 +4747,7 @@ export function ZoomInfoIcon(props: SVGProps) { return (

+ {landingContent.install.heading} +

+

+ {landingContent.install.intro} +

+
    + {landingContent.install.steps.map((item, index) => ( +
  1. + +
    +

    + {item.title} +

    +

    + {item.body} +

    +
    +
  2. + ))} +
+
+ + Add to {name} + +
+ +
+ + )} + + {/* Privacy & data (integration-specific) */} + {landingContent?.privacy && ( + <> +
+

+ Privacy & data +

+

+ {landingContent.privacy.body}{' '} + + Privacy Policy + + . +

+
+
+ + )} + {/* How to automate */}

= { ashby: AshbyIcon, athena: AthenaIcon, attio: AttioIcon, - azure_devops: AzureDevOpsIcon, + azure_devops: AzureIcon, box: BoxCompanyIcon, brandfetch: BrandfetchIcon, brightdata: BrightDataIcon, @@ -262,6 +262,7 @@ export const blockTypeToIconMap: Record = { elevenlabs: ElevenLabsIcon, emailbison: EmailBisonIcon, enrich: EnrichSoIcon, + enrichment: EnrichmentIcon, evernote: EvernoteIcon, exa: ExaAIIcon, extend_v2: ExtendIcon, diff --git a/apps/sim/app/(landing)/integrations/data/integrations.json b/apps/sim/app/(landing)/integrations/data/integrations.json index d859c6c322..8fc5c3856f 100644 --- a/apps/sim/app/(landing)/integrations/data/integrations.json +++ b/apps/sim/app/(landing)/integrations/data/integrations.json @@ -1889,7 +1889,7 @@ "description": "Interact with Azure DevOps pipelines, builds, and work items", "longDescription": "Integrate Azure DevOps into your workflow. List and inspect pipelines and builds, query and manage work items, and add or read comments.", "bgColor": "#0078D4", - "iconName": "AzureDevOpsIcon", + "iconName": "AzureIcon", "docsUrl": "https://docs.sim.ai/tools/azure_devops", "operations": [ { @@ -3137,6 +3137,24 @@ "integrationTypes": ["analytics", "developer-tools"], "tags": ["data-analytics", "automation"] }, + { + "type": "enrichment", + "slug": "data-enrichment", + "name": "Data Enrichment", + "description": "Enrich data with a Sim enrichment", + "longDescription": "Run a Sim enrichment to look up data — work email, phone number, company domain, company info, and more — from the fields you map in. Uses the same provider cascade as table enrichments.", + "bgColor": "#9333EA", + "iconName": "EnrichmentIcon", + "docsUrl": "https://docs.sim.ai/tools/enrichment", + "operations": [], + "operationCount": 0, + "triggers": [], + "triggerCount": 0, + "authType": "none", + "category": "tools", + "integrationTypes": ["sales"], + "tags": ["enrichment"] + }, { "type": "databricks", "slug": "databricks", @@ -13288,7 +13306,35 @@ "authType": "oauth", "category": "tools", "integrationTypes": ["communication", "developer-tools"], - "tags": ["messaging", "webhooks", "automation"] + "tags": ["messaging", "webhooks", "automation"], + "landingContent": { + "install": { + "heading": "Add Sim to your Slack workspace", + "intro": "Sim connects to Slack through Slack’s official OAuth flow. The “Add to Slack” button lives inside your Sim account (after sign-in) — connect from there and the Sim bot is installed in your Slack workspace. The steps below show exactly how to reach it.", + "steps": [ + { + "title": "Create your free Sim account", + "body": "Sign up at sim.ai — no credit card required." + }, + { + "title": "Add a Slack block", + "body": "Open a workflow, drag in a Slack block, and open its credential dropdown." + }, + { + "title": "Connect Slack", + "body": "Click Connect Slack, choose your workspace, and approve the requested permissions. This installs the Sim bot in your Slack workspace." + }, + { + "title": "Invite the bot and build", + "body": "Invite the Sim bot to the channels it should act in, pick a Slack action, wire it into your agent, and run." + } + ] + }, + "privacy": { + "body": "Sim requests only the Slack permissions its actions and triggers need, and never shows private channel names or messages to people who are not members of those channels in Slack.", + "href": "/privacy" + } + } }, { "type": "smtp", diff --git a/apps/sim/app/(landing)/integrations/data/landing-content.ts b/apps/sim/app/(landing)/integrations/data/landing-content.ts new file mode 100644 index 0000000000..7004c1f785 --- /dev/null +++ b/apps/sim/app/(landing)/integrations/data/landing-content.ts @@ -0,0 +1,41 @@ +/** + * Hand-authored, integration-specific landing content, keyed by integration + * slug. This is a pure-data generation input: `scripts/generate-docs.ts` reads + * it and bakes the matching entry into `integrations.json`, so the landing page + * consumes a single source (`integration.landingContent`) with no render-time + * augmentation. Has no app imports so the build script can import it safely. + */ + +import type { IntegrationLandingContent } from '@/app/(landing)/integrations/data/types' + +export const INTEGRATION_LANDING_CONTENT: Record = { + slack: { + install: { + heading: 'Add Sim to your Slack workspace', + intro: + 'Sim connects to Slack through Slack’s official OAuth flow. The “Add to Slack” button lives inside your Sim account (after sign-in) — connect from there and the Sim bot is installed in your Slack workspace. The steps below show exactly how to reach it.', + steps: [ + { + title: 'Create your free Sim account', + body: 'Sign up at sim.ai — no credit card required.', + }, + { + title: 'Add a Slack block', + body: 'Open a workflow, drag in a Slack block, and open its credential dropdown.', + }, + { + title: 'Connect Slack', + body: 'Click Connect Slack, choose your workspace, and approve the requested permissions. This installs the Sim bot in your Slack workspace.', + }, + { + title: 'Invite the bot and build', + body: 'Invite the Sim bot to the channels it should act in, pick a Slack action, wire it into your agent, and run.', + }, + ], + }, + privacy: { + body: 'Sim requests only the Slack permissions its actions and triggers need, and never shows private channel names or messages to people who are not members of those channels in Slack.', + href: '/privacy', + }, + }, +} diff --git a/apps/sim/app/(landing)/integrations/data/types.ts b/apps/sim/app/(landing)/integrations/data/types.ts index 7ba6483d6b..bcdc0f732a 100644 --- a/apps/sim/app/(landing)/integrations/data/types.ts +++ b/apps/sim/app/(landing)/integrations/data/types.ts @@ -19,6 +19,29 @@ export interface FAQItem { answer: string } +export interface IntegrationInstallStep { + title: string + body: string +} + +export interface IntegrationLandingContent { + /** + * Install walkthrough for OAuth apps whose connection lives behind sign-in. + * Provides the "Add to {app}" instructions that app marketplaces require + * when the install button sits behind a login. + */ + install?: { + heading: string + intro: string + steps: IntegrationInstallStep[] + } + /** Short data-handling summary shown next to a privacy-policy link. */ + privacy?: { + body: string + href: string + } +} + export interface Integration { type: string slug: string @@ -36,4 +59,5 @@ export interface Integration { category: string integrationTypes?: string[] tags?: string[] + landingContent?: IntegrationLandingContent } diff --git a/scripts/generate-docs.ts b/scripts/generate-docs.ts index c2c50cbbc6..9b806264a4 100755 --- a/scripts/generate-docs.ts +++ b/scripts/generate-docs.ts @@ -1,7 +1,7 @@ #!/usr/bin/env ts-node import fs from 'fs' import path from 'path' -import { fileURLToPath } from 'url' +import { fileURLToPath, pathToFileURL } from 'url' import { glob } from 'glob' console.log('Starting documentation generator...') @@ -183,6 +183,7 @@ interface IntegrationEntry { category: string integrationTypes?: string[] tags?: string[] + landingContent?: Record } /** @@ -665,6 +666,19 @@ async function writeIntegrationsJson(iconMapping: Record): Promi const triggerRegistry = await buildTriggerRegistry() const { desc: toolDescMap, name: toolNameMap } = await buildToolDescriptionMap() + + // Hand-authored, integration-specific landing content (install walkthrough, + // privacy blurb), keyed by slug. Imported as pure data — its only import is + // type-only and erased at runtime — and baked into the entries below so the + // landing page reads a single source instead of augmenting at render time. + const landingContentModule = await import( + pathToFileURL(path.join(LANDING_INTEGRATIONS_DATA_PATH, 'landing-content.ts')).href + ) + const landingContentMap = (landingContentModule.INTEGRATION_LANDING_CONTENT ?? {}) as Record< + string, + Record + > + const integrations: IntegrationEntry[] = [] const seenBaseTypes = new Set() const blockFiles = (await glob(`${BLOCKS_PATH}/*.ts`)).sort() @@ -778,6 +792,7 @@ async function writeIntegrationsJson(iconMapping: Record): Promi } : {}), ...(config.tags ? { tags: config.tags } : {}), + ...(landingContentMap[slug] ? { landingContent: landingContentMap[slug] } : {}), }) } } From f9867c7331e8be4fa9b4a019c70d93170148664c Mon Sep 17 00:00:00 2001 From: Waleed Date: Fri, 29 May 2026 16:49:58 -0700 Subject: [PATCH 07/29] improvement(enrichments): align enrichments sidebar with design system (#4801) * improvement(enrichments): align enrichments sidebar with design system * fix(enrichments): consistent close button pattern and fix url link hover --- .../enrichments-sidebar/enrichment-config.tsx | 3 +- .../enrichments-sidebar.tsx | 30 ++++++++----------- .../table-grid/cells/cell-render.tsx | 2 +- apps/sim/enrichments/work-email/work-email.ts | 2 +- 4 files changed, 16 insertions(+), 21 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/enrichments-sidebar/enrichment-config.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/enrichments-sidebar/enrichment-config.tsx index 2e02179785..257c66861a 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/enrichments-sidebar/enrichment-config.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/enrichments-sidebar/enrichment-config.tsx @@ -3,7 +3,6 @@ import { useState } from 'react' import { toError } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' -import { X } from 'lucide-react' import { Badge, Button, @@ -15,7 +14,7 @@ import { Switch, toast, } from '@/components/emcn' -import { ArrowLeft } from '@/components/emcn/icons' +import { ArrowLeft, X } from '@/components/emcn/icons' import type { AddWorkflowGroupBodyInput } from '@/lib/api/contracts/tables' import { cn } from '@/lib/core/utils/cn' import type { ColumnDefinition, WorkflowGroup, WorkflowGroupOutput } from '@/lib/table' diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/enrichments-sidebar/enrichments-sidebar.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/enrichments-sidebar/enrichments-sidebar.tsx index b77fc386db..a2575b4b43 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/enrichments-sidebar/enrichments-sidebar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/enrichments-sidebar/enrichments-sidebar.tsx @@ -1,9 +1,8 @@ 'use client' import { useState } from 'react' -import { X } from 'lucide-react' -import { Button, Input } from '@/components/emcn' -import { Search } from '@/components/emcn/icons' +import { Input } from '@/components/emcn' +import { Search, X } from '@/components/emcn/icons' import { cn } from '@/lib/core/utils/cn' import type { ColumnDefinition, WorkflowGroup } from '@/lib/table' import { ALL_ENRICHMENTS } from '@/enrichments' @@ -75,15 +74,14 @@ function EnrichmentsSidebarBody({

Enrichment

- +

@@ -121,15 +119,14 @@ function EnrichmentsSidebarBody({

Enrichments

- +
@@ -155,11 +152,10 @@ function EnrichmentsSidebarBody({ const Icon = enrichment.icon return (
  • - +
  • ) })} 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 91cabea799..fe6a6bfd3d 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 @@ -320,7 +320,7 @@ export function CellRender({ kind, isEditing }: CellRenderProps): React.ReactEle target='_blank' rel='noopener noreferrer' className={cn( - 'min-w-0 overflow-clip text-ellipsis text-[var(--text-primary)] underline underline-offset-2 hover:opacity-70', + 'min-w-0 overflow-clip text-ellipsis text-[var(--text-primary)] underline underline-offset-2 transition-colors hover-hover:text-[var(--text-secondary)]', isEditing && 'pointer-events-none' )} onClick={(e) => e.stopPropagation()} diff --git a/apps/sim/enrichments/work-email/work-email.ts b/apps/sim/enrichments/work-email/work-email.ts index 4166217a1c..2076e18eb4 100644 --- a/apps/sim/enrichments/work-email/work-email.ts +++ b/apps/sim/enrichments/work-email/work-email.ts @@ -1,5 +1,5 @@ import { filterUndefined } from '@sim/utils/object' -import { Mail } from 'lucide-react' +import { Mail } from '@/components/emcn/icons' import { normalizeDomain, splitName, str, toolProvider } from '@/enrichments/providers' import type { EnrichmentConfig } from '@/enrichments/types' From c51c41f0f9b31155a97237ba28eea6965a177a12 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Fri, 29 May 2026 17:29:52 -0700 Subject: [PATCH 08/29] fix(misc): upgrade path change for new better-auth version, billing issue for workflow block agent usage (#4803) * fix(misc): upgrade path change for new better-auth version, double-billing for workflow block agent usage * fail loudly if stripe sub id missing --- apps/sim/lib/billing/client/upgrade.ts | 47 ++++++++--- .../logs/execution/logging-factory.test.ts | 82 +++++++++++++++++++ .../sim/lib/logs/execution/logging-factory.ts | 23 ++++-- 3 files changed, 135 insertions(+), 17 deletions(-) diff --git a/apps/sim/lib/billing/client/upgrade.ts b/apps/sim/lib/billing/client/upgrade.ts index bbd0b5f8fe..eae23a285d 100644 --- a/apps/sim/lib/billing/client/upgrade.ts +++ b/apps/sim/lib/billing/client/upgrade.ts @@ -41,7 +41,8 @@ export function useSubscriptionUpgrade() { throw new Error('User not authenticated') } - let currentSubscriptionId: string | undefined + let currentSubscriptionRowId: string | undefined + let currentStripeSubscriptionId: string | undefined let allSubscriptions: any[] = [] try { const listResult = await client.subscription.list() @@ -49,9 +50,22 @@ export function useSubscriptionUpgrade() { const activePersonalSub = allSubscriptions.find( (sub: any) => hasPaidSubscriptionStatus(sub.status) && sub.referenceId === userId ) - currentSubscriptionId = activePersonalSub?.id + currentSubscriptionRowId = activePersonalSub?.id + currentStripeSubscriptionId = activePersonalSub?.stripeSubscriptionId } catch (_e) { - currentSubscriptionId = undefined + currentSubscriptionRowId = undefined + currentStripeSubscriptionId = undefined + } + + if (currentSubscriptionRowId && !currentStripeSubscriptionId) { + logger.error('Active paid subscription is missing its Stripe subscription ID', { + userId, + subscriptionRowId: currentSubscriptionRowId, + targetPlan, + }) + throw new Error( + 'We could not match your current plan with our payment provider. Please contact support before upgrading so you are not charged twice.' + ) } let referenceId = userId @@ -137,36 +151,45 @@ export function useSubscriptionUpgrade() { ...(annual && { annual: true }), } as const - const finalParams = currentSubscriptionId - ? { ...upgradeParams, subscriptionId: currentSubscriptionId } + const finalParams = currentStripeSubscriptionId + ? { ...upgradeParams, subscriptionId: currentStripeSubscriptionId } : upgradeParams logger.info( - currentSubscriptionId ? 'Upgrading existing subscription' : 'Creating new subscription', - { targetPlan, planName, annual, currentSubscriptionId, referenceId } + currentStripeSubscriptionId + ? 'Upgrading existing subscription' + : 'Creating new subscription', + { + targetPlan, + planName, + annual, + currentStripeSubscriptionId, + currentSubscriptionRowId, + referenceId, + } ) await betterAuthSubscription.upgrade(finalParams) - if (targetPlan === 'team' && currentSubscriptionId && referenceId !== userId) { + if (targetPlan === 'team' && currentSubscriptionRowId && referenceId !== userId) { try { logger.info('Transferring subscription to organization after upgrade', { - subscriptionId: currentSubscriptionId, + subscriptionId: currentSubscriptionRowId, organizationId: referenceId, }) try { await requestJson(subscriptionTransferContract, { - params: { id: currentSubscriptionId }, + params: { id: currentSubscriptionRowId }, body: { organizationId: referenceId }, }) logger.info('Successfully transferred subscription to organization', { - subscriptionId: currentSubscriptionId, + subscriptionId: currentSubscriptionRowId, organizationId: referenceId, }) } catch (transferError) { logger.error('Failed to transfer subscription to organization', { - subscriptionId: currentSubscriptionId, + subscriptionId: currentSubscriptionRowId, organizationId: referenceId, error: transferError instanceof ApiClientError diff --git a/apps/sim/lib/logs/execution/logging-factory.test.ts b/apps/sim/lib/logs/execution/logging-factory.test.ts index 6260165077..7ec7fe11d1 100644 --- a/apps/sim/lib/logs/execution/logging-factory.test.ts +++ b/apps/sim/lib/logs/execution/logging-factory.test.ts @@ -576,4 +576,86 @@ describe('calculateCostSummary', () => { expect(Object.keys(result.charges)).toHaveLength(0) expect(result.totalCost).toBe(BASE_EXECUTION_CHARGE) }) + + test('does not double-count the synthetic workflow root (aggregate cost over leaves)', () => { + // buildTraceSpans wraps every run in a synthetic { type: 'workflow' } root + // whose cost.total is the SUM of its leaves. Counting that root in addition + // to the leaves double-charges the run — the root must be a pass-through. + const traceSpans = [ + { + id: 'workflow-execution', + name: 'Workflow Execution', + type: 'workflow', + cost: { total: 0.04 }, // == agent(0.03) + exa(0.01) + children: [ + { + id: 'agent-1', + name: 'Agent', + type: 'agent', + model: 'gpt-4o', + cost: { input: 0.01, output: 0.02, total: 0.03 }, + tokens: { input: 100, output: 200, total: 300 }, + }, + { + id: 'exa-1', + name: 'Exa Search', + type: 'tool', + cost: { input: 0, output: 0, total: 0.01 }, + }, + ], + }, + ] + + const result = calculateCostSummary(traceSpans) + + // The 0.04 root aggregate is NOT added on top of its leaves. + expect(result.charges['Workflow Execution']).toBeUndefined() + expect(result.models['gpt-4o'].total).toBe(0.03) + expect(result.charges['Exa Search'].total).toBe(0.01) + expect(result.totalCost).toBeCloseTo(0.04 + BASE_EXECUTION_CHARGE, 10) + const ledgerSum = + result.baseExecutionCharge + + Object.values(result.models).reduce((s, m) => s + m.total, 0) + + Object.values(result.charges).reduce((s, c) => s + c.total, 0) + expect(ledgerSum).toBeCloseTo(result.totalCost, 10) + }) + + test('does not double-count nested sub-workflow roots', () => { + // A sub-workflow call nests another synthetic { type: 'workflow' } root + // (captureChildWorkflowLogs runs buildTraceSpans on the child). Both the + // outer root and the inner sub-workflow root carry aggregate costs; only the + // leaf agent inside should be billed. + const traceSpans = [ + { + id: 'workflow-execution', + name: 'Workflow Execution', + type: 'workflow', + cost: { total: 0.03 }, + children: [ + { + id: 'subworkflow-root', + name: 'Workflow Execution', + type: 'workflow', + cost: { total: 0.03 }, + children: [ + { + id: 'child-agent', + name: 'Agent', + type: 'agent', + model: 'gpt-4o', + cost: { input: 0.01, output: 0.02, total: 0.03 }, + tokens: { input: 100, output: 200, total: 300 }, + }, + ], + }, + ], + }, + ] + + const result = calculateCostSummary(traceSpans) + + expect(result.charges['Workflow Execution']).toBeUndefined() + expect(result.models['gpt-4o'].total).toBe(0.03) + expect(result.totalCost).toBeCloseTo(0.03 + BASE_EXECUTION_CHARGE, 10) + }) }) diff --git a/apps/sim/lib/logs/execution/logging-factory.ts b/apps/sim/lib/logs/execution/logging-factory.ts index 33582faab0..35699dde70 100644 --- a/apps/sim/lib/logs/execution/logging-factory.ts +++ b/apps/sim/lib/logs/execution/logging-factory.ts @@ -165,19 +165,32 @@ export function calculateCostSummary(traceSpans: CostTraceSpan[] | undefined): C const costSpans: BillableTraceSpan[] = [] for (const span of spans) { + // `workflow`-typed spans are aggregate containers, not billable units: the + // synthetic "Workflow Execution" root (added to every run by + // buildTraceSpans) and any nested sub-workflow root carry a `cost.total` + // equal to the SUM of their descendants. Counting that aggregate in + // addition to the descendants double-charges the run, so treat these as + // pass-through: never count their own cost, always recurse into all + // children where the real billable leaves (agents, tools) live. + const isAggregateContainer = span.type === 'workflow' const hasOwnCost = hasBillableCost(span) - if (hasOwnCost) { + const countOwnCost = hasOwnCost && !isAggregateContainer + + if (countOwnCost) { costSpans.push(span) } if (span.children && Array.isArray(span.children)) { - if (hasOwnCost) { - // Parent already accounts for its model segments; only recurse into - // non-model children (e.g. nested workflow spans) to find further - // billable units. + if (countOwnCost) { + // Authoritative leaf (e.g. an agent block whose block-level cost is set + // by the provider response and already accounts for its model + // segments): only recurse into non-model children to find further + // standalone billable units, skipping the model-breakdown duplicates. const nonModelChildren = span.children.filter((child) => !isModelBreakdownSpan(child)) costSpans.push(...collectCostSpans(nonModelChildren)) } else { + // Container (workflow / sub-workflow root) or a no-cost parent: recurse + // into everything so nested billable leaves are counted exactly once. costSpans.push(...collectCostSpans(span.children)) } } From 15ca66f1124656cb3229100158a76ddfbda6660b Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Fri, 29 May 2026 18:50:51 -0700 Subject: [PATCH 09/29] fix(copilot): seq migration (#4804) --- .../logs/execution/logging-factory.test.ts | 54 ++++++++++++++++++ packages/db/.env.swp | Bin 12288 -> 0 bytes packages/db/migrations/0219_amused_leo.sql | 4 +- 3 files changed, 56 insertions(+), 2 deletions(-) delete mode 100644 packages/db/.env.swp diff --git a/apps/sim/lib/logs/execution/logging-factory.test.ts b/apps/sim/lib/logs/execution/logging-factory.test.ts index 7ec7fe11d1..12c604aa42 100644 --- a/apps/sim/lib/logs/execution/logging-factory.test.ts +++ b/apps/sim/lib/logs/execution/logging-factory.test.ts @@ -658,4 +658,58 @@ describe('calculateCostSummary', () => { expect(result.models['gpt-4o'].total).toBe(0.03) expect(result.totalCost).toBeCloseTo(0.03 + BASE_EXECUTION_CHARGE, 10) }) + + test('does not double-count deeply nested (3-level) sub-workflow roots', () => { + // A → B → C: each level is its own synthetic { type: 'workflow' } root with + // an aggregate cost. Only the leaf agents at the bottom must be billed, once. + const leafAgent = (id: string, model: string, total: number) => ({ + id, + name: 'Agent', + type: 'agent', + model, + cost: { input: total / 3, output: (total * 2) / 3, total }, + tokens: { input: 100, output: 200, total: 300 }, + }) + + const traceSpans = [ + { + id: 'root', + name: 'Workflow Execution', + type: 'workflow', + cost: { total: 0.06 }, // aggregate of everything below + children: [ + leafAgent('parent-agent', 'gpt-4o', 0.02), + { + id: 'sub-a-root', + name: 'Workflow Execution', + type: 'workflow', + cost: { total: 0.04 }, + children: [ + leafAgent('a-agent', 'gpt-4o', 0.01), + { + id: 'sub-b-root', + name: 'Workflow Execution', + type: 'workflow', + cost: { total: 0.03 }, + children: [leafAgent('b-agent', 'claude-sonnet-4-6', 0.03)], + }, + ], + }, + ], + }, + ] + + const result = calculateCostSummary(traceSpans) + + expect(result.charges['Workflow Execution']).toBeUndefined() + // gpt-4o appears at two levels (0.02 + 0.01) and merges by model name. + expect(result.models['gpt-4o'].total).toBeCloseTo(0.03, 10) + expect(result.models['claude-sonnet-4-6'].total).toBeCloseTo(0.03, 10) + expect(result.totalCost).toBeCloseTo(0.06 + BASE_EXECUTION_CHARGE, 10) + const ledgerSum = + result.baseExecutionCharge + + Object.values(result.models).reduce((s, m) => s + m.total, 0) + + Object.values(result.charges).reduce((s, c) => s + c.total, 0) + expect(ledgerSum).toBeCloseTo(result.totalCost, 10) + }) }) diff --git a/packages/db/.env.swp b/packages/db/.env.swp deleted file mode 100644 index 27153b0737ce866350e9e2be1f2d286f244ca2e2..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 12288 zcmeI&%}T>S5Ww+i@1p1n)Oc>UUmgqyrc_X{AZoozzGyB*leuU1`dowr@S%y(?Z|A{WFTB*x z!!o5VCbm1$I%=P%x;^Z~+O^}lGua8%5|c$aIO;DjqI79DbGk>^d3w_=q4|Eu$VIe#tJ2m}y7009ILKmY**5I_I{ z1Q7T?fw6UEjjYS#o9V^bp^@k;pQV0w8=Fd16SI0b`o5DN6glSpaL_NW+h_Tm7ivSk E0QbB{H2?qr diff --git a/packages/db/migrations/0219_amused_leo.sql b/packages/db/migrations/0219_amused_leo.sql index c32c029cac..36dcb651eb 100644 --- a/packages/db/migrations/0219_amused_leo.sql +++ b/packages/db/migrations/0219_amused_leo.sql @@ -1,4 +1,4 @@ -ALTER TABLE "copilot_messages" ADD COLUMN "seq" integer;--> statement-breakpoint +ALTER TABLE "copilot_messages" ADD COLUMN IF NOT EXISTS "seq" integer;--> statement-breakpoint WITH ordered AS ( SELECT c."id" AS chat_id, elem.value->>'id' AS message_id, elem.ord AS ord FROM "copilot_chats" c @@ -16,4 +16,4 @@ ranked AS ( UPDATE "copilot_messages" m SET "seq" = r.seq FROM ranked r WHERE m."chat_id" = r.chat_id AND m."message_id" = r.message_id;--> statement-breakpoint -CREATE INDEX "copilot_messages_chat_seq_idx" ON "copilot_messages" USING btree ("chat_id","seq") WHERE "copilot_messages"."deleted_at" IS NULL; \ No newline at end of file +CREATE INDEX IF NOT EXISTS "copilot_messages_chat_seq_idx" ON "copilot_messages" USING btree ("chat_id","seq") WHERE "copilot_messages"."deleted_at" IS NULL; \ No newline at end of file From 75d98739ebd8a1f51bbd2be72d09dd849ed03599 Mon Sep 17 00:00:00 2001 From: Waleed Date: Fri, 29 May 2026 20:31:37 -0700 Subject: [PATCH 10/29] chore(db): drop redundant idx_webhook_on_workflow_id_block_id index (#4809) Removed because (workflow_id, block_id) is a left-prefix of idx_webhook_on_workflow_id_block_id_updated_at_desc, which fully covers it. The dropped index was non-unique and enforced no constraint. --- .../migrations/0221_secret_hannibal_king.sql | 1 + .../db/migrations/meta/0221_snapshot.json | 17561 ++++++++++++++++ packages/db/migrations/meta/_journal.json | 7 + packages/db/schema.ts | 5 - 4 files changed, 17569 insertions(+), 5 deletions(-) create mode 100644 packages/db/migrations/0221_secret_hannibal_king.sql create mode 100644 packages/db/migrations/meta/0221_snapshot.json diff --git a/packages/db/migrations/0221_secret_hannibal_king.sql b/packages/db/migrations/0221_secret_hannibal_king.sql new file mode 100644 index 0000000000..ecda911cf0 --- /dev/null +++ b/packages/db/migrations/0221_secret_hannibal_king.sql @@ -0,0 +1 @@ +DROP INDEX "idx_webhook_on_workflow_id_block_id"; \ No newline at end of file diff --git a/packages/db/migrations/meta/0221_snapshot.json b/packages/db/migrations/meta/0221_snapshot.json new file mode 100644 index 0000000000..56036c9cb0 --- /dev/null +++ b/packages/db/migrations/meta/0221_snapshot.json @@ -0,0 +1,17561 @@ +{ + "id": "3660eb7f-6a5c-4409-abc0-cc9a404cfdf6", + "prevId": "10027d61-99fa-4082-99a8-27be35fc3d8a", + "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": {} + }, + "async_jobs_schedule_pending_run_at_idx": { + "name": "async_jobs_schedule_pending_run_at_idx", + "columns": [ + { + "expression": "run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"async_jobs\".\"type\" = 'schedule-execution' AND \"async_jobs\".\"status\" = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_schedule_processing_started_at_idx": { + "name": "async_jobs_schedule_processing_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"async_jobs\".\"type\" = 'schedule-execution' AND \"async_jobs\".\"status\" = 'processing'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "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_messages": { + "name": "copilot_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "stream_id": { + "name": "stream_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parent_message_id": { + "name": "parent_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tokens_in": { + "name": "tokens_in", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "tokens_out": { + "name": "tokens_out", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_messages_chat_message_unique": { + "name": "copilot_messages_chat_message_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_chat_created_at_idx": { + "name": "copilot_messages_chat_created_at_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_chat_seq_idx": { + "name": "copilot_messages_chat_seq_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "seq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_chat_stream_idx": { + "name": "copilot_messages_chat_stream_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "stream_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"stream_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_messages_chat_id_copilot_chats_id_fk": { + "name": "copilot_messages_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_messages", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_run_checkpoints": { + "name": "copilot_run_checkpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "pending_tool_call_id": { + "name": "pending_tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conversation_snapshot": { + "name": "conversation_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "agent_state": { + "name": "agent_state", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "provider_request": { + "name": "provider_request", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_run_checkpoints_run_id_idx": { + "name": "copilot_run_checkpoints_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_run_checkpoints_pending_tool_call_id_idx": { + "name": "copilot_run_checkpoints_pending_tool_call_id_idx", + "columns": [ + { + "expression": "pending_tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_run_checkpoints_run_pending_tool_unique": { + "name": "copilot_run_checkpoints_run_pending_tool_unique", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pending_tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_run_checkpoints_run_id_copilot_runs_id_fk": { + "name": "copilot_run_checkpoints_run_id_copilot_runs_id_fk", + "tableFrom": "copilot_run_checkpoints", + "tableTo": "copilot_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_runs": { + "name": "copilot_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_run_id": { + "name": "parent_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stream_id": { + "name": "stream_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent": { + "name": "agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "copilot_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "request_context": { + "name": "request_context", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "copilot_runs_execution_id_idx": { + "name": "copilot_runs_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_parent_run_id_idx": { + "name": "copilot_runs_parent_run_id_idx", + "columns": [ + { + "expression": "parent_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_chat_id_idx": { + "name": "copilot_runs_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_user_id_idx": { + "name": "copilot_runs_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_workflow_id_idx": { + "name": "copilot_runs_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_workspace_id_idx": { + "name": "copilot_runs_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_status_idx": { + "name": "copilot_runs_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_chat_execution_idx": { + "name": "copilot_runs_chat_execution_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_execution_started_at_idx": { + "name": "copilot_runs_execution_started_at_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_workspace_completed_at_id_idx": { + "name": "copilot_runs_workspace_completed_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"completed_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_stream_id_unique": { + "name": "copilot_runs_stream_id_unique", + "columns": [ + { + "expression": "stream_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_runs_chat_id_copilot_chats_id_fk": { + "name": "copilot_runs_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_user_id_user_id_fk": { + "name": "copilot_runs_user_id_user_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_workflow_id_workflow_id_fk": { + "name": "copilot_runs_workflow_id_workflow_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_workspace_id_workspace_id_fk": { + "name": "copilot_runs_workspace_id_workspace_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_workflow_read_hashes": { + "name": "copilot_workflow_read_hashes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_workflow_read_hashes_chat_id_idx": { + "name": "copilot_workflow_read_hashes_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_workflow_read_hashes_workflow_id_idx": { + "name": "copilot_workflow_read_hashes_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_workflow_read_hashes_chat_workflow_unique": { + "name": "copilot_workflow_read_hashes_chat_workflow_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_workflow_read_hashes_chat_id_copilot_chats_id_fk": { + "name": "copilot_workflow_read_hashes_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_workflow_read_hashes", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_workflow_read_hashes_workflow_id_workflow_id_fk": { + "name": "copilot_workflow_read_hashes_workflow_id_workflow_id_fk", + "tableFrom": "copilot_workflow_read_hashes", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credential": { + "name": "credential", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "credential_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "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.execution_large_value_dependencies": { + "name": "execution_large_value_dependencies", + "schema": "", + "columns": { + "parent_key": { + "name": "parent_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_key": { + "name": "child_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "execution_large_value_dependencies_workspace_parent_key_idx": { + "name": "execution_large_value_dependencies_workspace_parent_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_value_dependencies_workspace_child_key_idx": { + "name": "execution_large_value_dependencies_workspace_child_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "child_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "execution_large_value_dependencies_workspace_id_workspace_id_fk": { + "name": "execution_large_value_dependencies_workspace_id_workspace_id_fk", + "tableFrom": "execution_large_value_dependencies", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "execution_large_value_dependencies_parent_key_child_key_pk": { + "name": "execution_large_value_dependencies_parent_key_child_key_pk", + "columns": ["parent_key", "child_key"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.execution_large_value_references": { + "name": "execution_large_value_references", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "execution_large_value_reference_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "execution_large_value_references_workspace_execution_source_idx": { + "name": "execution_large_value_references_workspace_execution_source_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "execution_large_value_references_workspace_id_workspace_id_fk": { + "name": "execution_large_value_references_workspace_id_workspace_id_fk", + "tableFrom": "execution_large_value_references", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "execution_large_value_references_workflow_id_workflow_id_fk": { + "name": "execution_large_value_references_workflow_id_workflow_id_fk", + "tableFrom": "execution_large_value_references", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "execution_large_value_references_key_execution_id_source_pk": { + "name": "execution_large_value_references_key_execution_id_source_pk", + "columns": ["key", "execution_id", "source"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.execution_large_values": { + "name": "execution_large_values", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_execution_id": { + "name": "owner_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "execution_large_values_owner_execution_id_idx": { + "name": "execution_large_values_owner_execution_id_idx", + "columns": [ + { + "expression": "owner_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_values_cleanup_idx": { + "name": "execution_large_values_cleanup_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"execution_large_values\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_values_tombstone_cleanup_idx": { + "name": "execution_large_values_tombstone_cleanup_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"execution_large_values\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "execution_large_values_workspace_id_workspace_id_fk": { + "name": "execution_large_values_workspace_id_workspace_id_fk", + "tableFrom": "execution_large_values", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "execution_large_values_workflow_id_workflow_id_fk": { + "name": "execution_large_values_workflow_id_workflow_id_fk", + "tableFrom": "execution_large_values", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.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 + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "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 + }, + "cancel_at": { + "name": "cancel_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "canceled_at": { + "name": "canceled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "seats": { + "name": "seats", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "trial_start": { + "name": "trial_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trial_end": { + "name": "trial_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "billing_interval": { + "name": "billing_interval", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_schedule_id": { + "name": "stripe_schedule_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false + } + }, + "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 + }, + "limit": { + "name": "limit", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "processed_count": { + "name": "processed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_manual_run": { + "name": "is_manual_run", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "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 + }, + "event_key": { + "name": "event_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "billing_entity_type": { + "name": "billing_entity_type", + "type": "billing_entity_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "billing_entity_id": { + "name": "billing_entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "billing_period_start": { + "name": "billing_period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "billing_period_end": { + "name": "billing_period_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "usage_log_user_created_at_idx": { + "name": "usage_log_user_created_at_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_source_idx": { + "name": "usage_log_source_idx", + "columns": [ + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_workspace_id_idx": { + "name": "usage_log_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_workflow_id_idx": { + "name": "usage_log_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_event_key_unique": { + "name": "usage_log_event_key_unique", + "columns": [ + { + "expression": "event_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"usage_log\".\"event_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_billing_entity_period_idx": { + "name": "usage_log_billing_entity_period_idx", + "columns": [ + { + "expression": "billing_entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_period_start", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_period_end", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_log\".\"billing_entity_type\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_workspace_created_at_idx": { + "name": "usage_log_workspace_created_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_execution_id_idx": { + "name": "usage_log_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "usage_log_user_id_user_id_fk": { + "name": "usage_log_user_id_user_id_fk", + "tableFrom": "usage_log", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "usage_log_workspace_id_workspace_id_fk": { + "name": "usage_log_workspace_id_workspace_id_fk", + "tableFrom": "usage_log", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "usage_log_workflow_id_workflow_id_fk": { + "name": "usage_log_workflow_id_workflow_id_fk", + "tableFrom": "usage_log", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "usage_log_billing_scope_all_or_none": { + "name": "usage_log_billing_scope_all_or_none", + "value": "(\n (\"usage_log\".\"billing_entity_type\" IS NULL AND \"usage_log\".\"billing_entity_id\" IS NULL AND \"usage_log\".\"billing_period_start\" IS NULL AND \"usage_log\".\"billing_period_end\" IS NULL)\n OR\n (\"usage_log\".\"billing_entity_type\" IS NOT NULL AND \"usage_log\".\"billing_entity_id\" IS NOT NULL AND \"usage_log\".\"billing_period_start\" IS NOT NULL AND \"usage_log\".\"billing_period_end\" IS NOT NULL AND \"usage_log\".\"billing_period_start\" < \"usage_log\".\"billing_period_end\")\n )" + } + }, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "normalized_email": { + "name": "normalized_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'user'" + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + }, + "user_normalized_email_unique": { + "name": "user_normalized_email_unique", + "nullsNotDistinct": false, + "columns": ["normalized_email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_stats": { + "name": "user_stats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "total_manual_executions": { + "name": "total_manual_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_api_calls": { + "name": "total_api_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_webhook_triggers": { + "name": "total_webhook_triggers", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_scheduled_executions": { + "name": "total_scheduled_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_chat_executions": { + "name": "total_chat_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_mcp_executions": { + "name": "total_mcp_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_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": {} + }, + "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 + }, + "cost_total": { + "name": "cost_total", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "models_used": { + "name": "models_used", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "files": { + "name": "files", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_execution_logs_workflow_id_idx": { + "name": "workflow_execution_logs_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_state_snapshot_id_idx": { + "name": "workflow_execution_logs_state_snapshot_id_idx", + "columns": [ + { + "expression": "state_snapshot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_deployment_version_id_idx": { + "name": "workflow_execution_logs_deployment_version_id_idx", + "columns": [ + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_trigger_idx": { + "name": "workflow_execution_logs_trigger_idx", + "columns": [ + { + "expression": "trigger", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_level_idx": { + "name": "workflow_execution_logs_level_idx", + "columns": [ + { + "expression": "level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_started_at_idx": { + "name": "workflow_execution_logs_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_execution_id_unique": { + "name": "workflow_execution_logs_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workflow_started_at_idx": { + "name": "workflow_execution_logs_workflow_started_at_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workspace_started_at_idx": { + "name": "workflow_execution_logs_workspace_started_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workspace_cost_total_idx": { + "name": "workflow_execution_logs_workspace_cost_total_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_total", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_models_used_idx": { + "name": "workflow_execution_logs_models_used_idx", + "columns": [ + { + "expression": "models_used", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "workflow_execution_logs_workspace_ended_at_id_idx": { + "name": "workflow_execution_logs_workspace_ended_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"ended_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_running_started_at_idx": { + "name": "workflow_execution_logs_running_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "status = 'running'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "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 + }, + "infra_retry_count": { + "name": "infra_retry_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "last_failed_at": { + "name": "last_failed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "source_type": { + "name": "source_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'workflow'" + }, + "job_title": { + "name": "job_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lifecycle": { + "name": "lifecycle", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'persistent'" + }, + "success_condition": { + "name": "success_condition", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "max_runs": { + "name": "max_runs", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "run_count": { + "name": "run_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "source_chat_id": { + "name": "source_chat_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_task_name": { + "name": "source_task_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_user_id": { + "name": "source_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_workspace_id": { + "name": "source_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "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": {} + }, + "workflow_schedule_due_workflow_idx": { + "name": "workflow_schedule_due_workflow_idx", + "columns": [ + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_schedule\".\"archived_at\" IS NULL AND \"workflow_schedule\".\"status\" NOT IN ('disabled', 'completed') AND (\"workflow_schedule\".\"source_type\" = 'workflow' OR \"workflow_schedule\".\"source_type\" IS NULL)", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_due_job_idx": { + "name": "workflow_schedule_due_job_idx", + "columns": [ + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_schedule\".\"archived_at\" IS NULL AND \"workflow_schedule\".\"status\" NOT IN ('disabled', 'completed') AND \"workflow_schedule\".\"source_type\" = 'job'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_schedule_workflow_id_workflow_id_fk": { + "name": "workflow_schedule_workflow_id_workflow_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_schedule_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_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.billing_entity_type": { + "name": "billing_entity_type", + "schema": "public", + "values": ["user", "organization"] + }, + "public.chat_type": { + "name": "chat_type", + "schema": "public", + "values": ["mothership", "copilot"] + }, + "public.copilot_async_tool_status": { + "name": "copilot_async_tool_status", + "schema": "public", + "values": ["pending", "running", "completed", "failed", "cancelled", "delivered"] + }, + "public.copilot_run_status": { + "name": "copilot_run_status", + "schema": "public", + "values": ["active", "paused_waiting_for_tool", "resuming", "complete", "error", "cancelled"] + }, + "public.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.execution_large_value_reference_source": { + "name": "execution_large_value_reference_source", + "schema": "public", + "values": ["execution_log", "paused_snapshot"] + }, + "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", "tool"] + }, + "public.usage_log_source": { + "name": "usage_log_source", + "schema": "public", + "values": [ + "workflow", + "wand", + "copilot", + "workspace-chat", + "mcp_copilot", + "mothership_block", + "knowledge-base", + "voice-input", + "enrichment" + ] + }, + "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 f348c087a6..864b641e53 100644 --- a/packages/db/migrations/meta/_journal.json +++ b/packages/db/migrations/meta/_journal.json @@ -1541,6 +1541,13 @@ "when": 1780081787541, "tag": "0220_early_hellion", "breakpoints": true + }, + { + "idx": 221, + "version": "7", + "when": 1780111474238, + "tag": "0221_secret_hannibal_king", + "breakpoints": true } ] } diff --git a/packages/db/schema.ts b/packages/db/schema.ts index 4e9b691a4c..6684deef38 100644 --- a/packages/db/schema.ts +++ b/packages/db/schema.ts @@ -747,11 +747,6 @@ export const webhook = pgTable( pathIdx: uniqueIndex('path_deployment_unique') .on(table.path, table.deploymentVersionId) .where(sql`${table.archivedAt} IS NULL`), - // Optimize queries for webhooks by workflow and block - workflowBlockIdx: index('idx_webhook_on_workflow_id_block_id').on( - table.workflowId, - table.blockId - ), workflowDeploymentIdx: index('webhook_workflow_deployment_idx').on( table.workflowId, table.deploymentVersionId From 640b7e12c58d2a979bd78e0c7fa3c771674230ad Mon Sep 17 00:00:00 2001 From: Waleed Date: Sat, 30 May 2026 10:10:51 -0700 Subject: [PATCH 11/29] perf(copilot): read chat transcripts from copilot_messages (R+1 cutover) (#4808) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * perf(copilot): read chat transcripts from copilot_messages, not JSONB Flip user-facing chat reads from the legacy copilot_chats.messages JSONB array (5.7GB, 99% TOAST) to the normalized copilot_messages table via a new loadCopilotChatMessages helper ordered by seq NULLS LAST, created_at, id — the verified canonical order. Both chat-detail getters (getAccessibleCopilotChat, getAccessibleCopilotChatWithMessages) now drop the messages column from their metadata select (no more whole-array detoast on every load) and assemble the transcript from the table after authorization. This cascades to the copilot + mothership GET endpoints and to resolveOrCreateChat's conversationHistory (the LLM payload). The normalize/effective-transcript pipeline is source-agnostic (copilot_messages.content == a JSONB array element), so transcripts are byte-identical. Dual-write and the JSONB column stay in place as the internal-logic source and fallback; removing JSONB writes is a later step. Prod integrity verified before cutover: 0 messages missing, 0 NULL-seq, 0 dup keys/seq, 0 orphans, order-parity vs JSONB = 0 mismatches. Co-Authored-By: Claude Opus 4.8 * test(copilot): cover auth-deny on a found row skips the messages query Address PR review: exercise the `if (!authorized) return null` contract — when the chat row exists but authorization fails, the getter returns null and never issues the copilot_messages read. Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- apps/sim/lib/copilot/chat/lifecycle.test.ts | 135 ++++++++++++++++++++ apps/sim/lib/copilot/chat/lifecycle.ts | 62 +++++++-- 2 files changed, 186 insertions(+), 11 deletions(-) create mode 100644 apps/sim/lib/copilot/chat/lifecycle.test.ts diff --git a/apps/sim/lib/copilot/chat/lifecycle.test.ts b/apps/sim/lib/copilot/chat/lifecycle.test.ts new file mode 100644 index 0000000000..df1689cc68 --- /dev/null +++ b/apps/sim/lib/copilot/chat/lifecycle.test.ts @@ -0,0 +1,135 @@ +/** + * @vitest-environment node + */ +import { dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@sim/db', () => dbChainMock) + +const { mockAuthorizeWorkflow, mockGetActiveWorkflow } = vi.hoisted(() => ({ + mockAuthorizeWorkflow: vi.fn(), + mockGetActiveWorkflow: vi.fn(), +})) + +vi.mock('@sim/workflow-authz', () => ({ + authorizeWorkflowByWorkspacePermission: mockAuthorizeWorkflow, + getActiveWorkflowRecord: mockGetActiveWorkflow, +})) + +vi.mock('@/lib/workspaces/permissions/utils', () => ({ + assertActiveWorkspaceAccess: vi.fn(), + checkWorkspaceAccess: vi.fn(), +})) + +import { + getAccessibleCopilotChat, + getAccessibleCopilotChatWithMessages, + resolveOrCreateChat, +} from '@/lib/copilot/chat/lifecycle' + +const CHAT_ID = 'chat-1' +const USER_ID = 'user-1' + +// A chat with no workflow/workspace skips the authz lookups and authorizes directly. +const chatRow = { + id: CHAT_ID, + userId: USER_ID, + workflowId: null, + workspaceId: null, + type: 'copilot', + title: 'Test', + conversationId: null, + resources: [], + createdAt: new Date('2026-01-01T00:00:00.000Z'), + updatedAt: new Date('2026-01-01T00:00:00.000Z'), +} + +const userMsg = { id: 'm-user', role: 'user', content: 'Hi', timestamp: '2026-01-01T00:00:00.000Z' } +const asstMsg = { + id: 'm-asst', + role: 'assistant', + content: 'Hello', + timestamp: '2026-01-01T00:00:01.000Z', +} + +describe('lifecycle copilot chat reads (cutover to copilot_messages)', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + + it('getAccessibleCopilotChatWithMessages sources messages from copilot_messages in seq order', async () => { + // 1st query: chat metadata (select().from().where().limit()) + dbChainMockFns.limit.mockResolvedValueOnce([chatRow]) + // 2nd query: messages (select().from().where().orderBy()) + dbChainMockFns.orderBy.mockResolvedValueOnce([{ content: userMsg }, { content: asstMsg }]) + + const result = await getAccessibleCopilotChatWithMessages(CHAT_ID, USER_ID) + + expect(result).not.toBeNull() + expect(result?.messages).toEqual([userMsg, asstMsg]) + expect(dbChainMockFns.orderBy).toHaveBeenCalledTimes(1) + }) + + it('returns an empty transcript for a chat with no messages', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([chatRow]) + dbChainMockFns.orderBy.mockResolvedValueOnce([]) + + const result = await getAccessibleCopilotChatWithMessages(CHAT_ID, USER_ID) + + expect(result?.messages).toEqual([]) + }) + + it('returns null and does NOT query messages when the chat is not found', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([]) + + const result = await getAccessibleCopilotChatWithMessages(CHAT_ID, USER_ID) + + expect(result).toBeNull() + expect(dbChainMockFns.orderBy).not.toHaveBeenCalled() + }) + + it('returns null and does NOT query messages when the row is found but authorization fails', async () => { + // Row exists but belongs to a workflow the user cannot read. + dbChainMockFns.limit.mockResolvedValueOnce([{ ...chatRow, workflowId: 'wf-1' }]) + mockAuthorizeWorkflow.mockResolvedValueOnce({ allowed: false, workflow: null }) + + const result = await getAccessibleCopilotChatWithMessages(CHAT_ID, USER_ID) + + expect(result).toBeNull() + expect(dbChainMockFns.orderBy).not.toHaveBeenCalled() + }) + + it('legacy getAccessibleCopilotChat also assembles messages from copilot_messages', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([ + { ...chatRow, model: 'm', planArtifact: null, config: null }, + ]) + dbChainMockFns.orderBy.mockResolvedValueOnce([{ content: userMsg }]) + + const result = await getAccessibleCopilotChat(CHAT_ID, USER_ID) + + expect(result?.messages).toEqual([userMsg]) + }) + + it('resolveOrCreateChat returns conversationHistory from the table for an existing chat', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([chatRow]) + dbChainMockFns.orderBy.mockResolvedValueOnce([{ content: userMsg }, { content: asstMsg }]) + + const result = await resolveOrCreateChat({ chatId: CHAT_ID, userId: USER_ID, model: 'm' }) + + expect(result.isNew).toBe(false) + expect(result.conversationHistory).toEqual([userMsg, asstMsg]) + }) + + it('resolveOrCreateChat creates a new chat with an empty transcript', async () => { + // insert().values().returning() -> fresh chat with empty messages + dbChainMockFns.returning.mockResolvedValueOnce([{ ...chatRow, messages: [] }]) + + const result = await resolveOrCreateChat({ userId: USER_ID, model: 'm' }) + + expect(result.isNew).toBe(true) + expect(result.conversationHistory).toEqual([]) + // a brand-new chat must not trigger a messages read + expect(dbChainMockFns.orderBy).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/copilot/chat/lifecycle.ts b/apps/sim/lib/copilot/chat/lifecycle.ts index 0a9802d0f6..e86c127c10 100644 --- a/apps/sim/lib/copilot/chat/lifecycle.ts +++ b/apps/sim/lib/copilot/chat/lifecycle.ts @@ -1,11 +1,11 @@ import { db } from '@sim/db' -import { copilotChats } from '@sim/db/schema' +import { copilotChats, copilotMessages } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { authorizeWorkflowByWorkspacePermission, getActiveWorkflowRecord, } from '@sim/workflow-authz' -import { and, eq } from 'drizzle-orm' +import { and, asc, eq, isNull, sql } from 'drizzle-orm' import { assertActiveWorkspaceAccess, checkWorkspaceAccess, @@ -35,22 +35,33 @@ const copilotChatAuthColumns = { } as const /** - * Column set for chat-detail callers that need the conversation transcript but - * not the copilot-only TOAST-able fields (`previewYaml`, `planArtifact`, - * `config`) or unused metadata (`model`, `pinned`, `lastSeenAt`). Selecting - * only these columns avoids the Postgres detoast cost on the dropped fields, - * which dominates latency for chats with large message histories. + * Column set for chat-detail callers that need chat metadata. The conversation + * transcript is no longer selected from `copilot_chats.messages` (JSONB) — + * reads now source it from the normalized `copilot_messages` table via + * `loadCopilotChatMessages`, which avoids detoasting the large messages blob on + * every load. The copilot-only TOAST-able fields (`previewYaml`, + * `planArtifact`, `config`) and unused metadata (`model`, `pinned`, + * `lastSeenAt`) remain excluded. */ const copilotChatDetailColumns = { ...copilotChatAuthColumns, title: copilotChats.title, - messages: copilotChats.messages, conversationId: copilotChats.conversationId, resources: copilotChats.resources, createdAt: copilotChats.createdAt, updatedAt: copilotChats.updatedAt, } as const +/** + * Returning column set for newly-inserted chats. A fresh chat has no + * `copilot_messages` rows yet, so the transcript is the just-inserted empty + * JSONB array — return it directly rather than issuing a second query. + */ +const copilotChatDetailReturningColumns = { + ...copilotChatDetailColumns, + messages: copilotChats.messages, +} as const + /** * Column set for the legacy copilot chat detail endpoint. Extends * `copilotChatDetailColumns` with `model`, `planArtifact`, and `config` — the @@ -64,6 +75,27 @@ const copilotChatLegacyDetailColumns = { config: copilotChats.config, } as const +/** + * Load a chat's transcript from the normalized `copilot_messages` table in + * canonical order (`seq` first, then `created_at`/`id` as a deterministic + * tiebreak; `NULLS LAST` so any not-yet-sequenced row sorts after sequenced + * ones). Each row's `content` is the full message object — identical in shape + * to a legacy JSONB array element — so the downstream normalize/transcript + * pipeline is unchanged. + */ +async function loadCopilotChatMessages(chatId: string): Promise[]> { + const rows = await db + .select({ content: copilotMessages.content }) + .from(copilotMessages) + .where(and(eq(copilotMessages.chatId, chatId), isNull(copilotMessages.deletedAt))) + .orderBy( + sql`${copilotMessages.seq} asc nulls last`, + asc(copilotMessages.createdAt), + asc(copilotMessages.id) + ) + return rows.map((row) => row.content as Record) +} + type CopilotChatAuthRow = Pick< typeof copilotChats.$inferSelect, 'id' | 'userId' | 'workflowId' | 'workspaceId' | 'type' @@ -160,7 +192,11 @@ export async function getAccessibleCopilotChat( .where(and(eq(copilotChats.id, chatId), eq(copilotChats.userId, userId))) .limit(1) - return authorizeCopilotChatRow(chat, chatId, userId) + const authorized = await authorizeCopilotChatRow(chat, chatId, userId) + if (!authorized) return null + + const messages = await loadCopilotChatMessages(chatId) + return { ...authorized, messages } } /** @@ -181,7 +217,11 @@ export async function getAccessibleCopilotChatWithMessages( .where(and(eq(copilotChats.id, chatId), eq(copilotChats.userId, userId))) .limit(1) - return authorizeCopilotChatRow(chat, chatId, userId) + const authorized = await authorizeCopilotChatRow(chat, chatId, userId) + if (!authorized) return null + + const messages = await loadCopilotChatMessages(chatId) + return { ...authorized, messages } } /** @@ -261,7 +301,7 @@ export async function resolveOrCreateChat(params: { messages: [], lastSeenAt: now, }) - .returning(copilotChatDetailColumns) + .returning(copilotChatDetailReturningColumns) if (!newChat) { logger.warn('Failed to create new copilot chat row', { userId, workflowId, workspaceId }) From b4787dd1739decb655a818fbb87ef21ec0a6d01b Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Sat, 30 May 2026 10:27:00 -0700 Subject: [PATCH 12/29] fix(tables): right-align run/stop in embedded toolbar; workflow cells format like normal cells (#4806) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(tables): right-align run/stop in the embedded table toolbar Add a right-aligned `trailing` slot to ResourceOptionsBar and move the embedded mothership table's run/stop control into it, so Filter + Sort stay left-aligned and run/stop sits opposite on the right. No-op for the search-bearing consumers (logs, resource list), which don't pass `trailing`. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(tables): workflow-output cells format values like normal cells Workflow-output columns short-circuited in resolveCellRender and rendered their value as plain text, so a sim-resource URL / external URL / JSON / date produced by a workflow never got the chip, favicon link, or typed formatting a normal cell gets. Factor value formatting into a shared `resolveValueKind` helper used by both the workflow-value branch and the plain-cell branch; the workflow branch keeps the typewriter reveal for plain streaming text via a `typewriter` flag. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(tables): detect resource/URL links on workflow output regardless of column type Workflow output columns default to `json` (columnTypeForLeaf), so routing their values through the type-based formatter (a) gated chip/URL promotion behind `column.type === 'string'` — a URL produced by a json-typed output never became a chip — and (b) JSON.stringify'd plain string values, adding quotes and losing the typewriter reveal. Detect links (sim-resource chip / favicon URL) on the value string directly for workflow outputs, falling back to the plain `value` kind; plain cells keep the type-based formatting. Addresses Greptile P2 on #4806. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- .../resource-options-bar.tsx | 14 ++++- .../table-grid/cells/cell-render.tsx | 63 ++++++++++++++----- .../[workspaceId]/tables/[tableId]/table.tsx | 8 +-- 3 files changed, 64 insertions(+), 21 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-options-bar/resource-options-bar.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-options-bar/resource-options-bar.tsx index fc3442c124..853349ed36 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-options-bar/resource-options-bar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-options-bar/resource-options-bar.tsx @@ -73,6 +73,10 @@ interface ResourceOptionsBarProps { filterActive?: boolean filterTags?: FilterTag[] extras?: ReactNode + /** Right-aligned slot. Unlike `extras` (which sits with the left controls), + * `trailing` is pushed to the far right via `justify-between` — used for the + * table's run/stop control opposite the left-aligned filter/sort. */ + trailing?: ReactNode } export const ResourceOptionsBar = memo(function ResourceOptionsBar({ @@ -83,9 +87,16 @@ export const ResourceOptionsBar = memo(function ResourceOptionsBar({ filterActive, filterTags, extras, + trailing, }: ResourceOptionsBarProps) { const hasContent = - search || sort || filter || onFilterToggle || extras || (filterTags && filterTags.length > 0) + search || + sort || + filter || + onFilterToggle || + extras || + trailing || + (filterTags && filterTags.length > 0) if (!hasContent) return null return ( @@ -143,6 +154,7 @@ export const ResourceOptionsBar = memo(function ResourceOptionsBar({ ) : null} {sort && }
    + {trailing &&
    {trailing}
    }
    ) 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 fe6a6bfd3d..065385a9f0 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 @@ -77,7 +77,13 @@ export function resolveCellRender({ // Value wins over pending-upstream: a finished column stays finished even // while other blocks in the group are still running. An empty string is not // a value — it falls through so a completed enrichment can show "Not found". - if (!isEmpty) return { kind: 'value', text: stringifyValue(value) } + // A value that's wholly a resource/URL string renders as a chip/link (any + // column type — workflow output is free-form); otherwise the plain `value` + // kind keeps the typewriter reveal for streaming text. + if (!isEmpty) { + const text = stringifyValue(value) + return resolveLinkKind(text, currentWorkspaceId) ?? { kind: 'value', text } + } if (inFlight && !(groupHasBlockErrors && !blockRunning)) { // A `pending` cell whose jobId starts with `paused-` is mid-pause @@ -109,21 +115,7 @@ export function resolveCellRender({ if (column.type === 'date') return { kind: 'date', text: String(value) } if (column.type === 'string') { const text = stringifyValue(value) - if (currentWorkspaceId) { - const resource = extractSimResourceInfo(text) - if (resource && resource.workspaceId === currentWorkspaceId) { - return { - kind: 'sim-resource', - workspaceId: resource.workspaceId, - resourceType: resource.resourceType, - resourceId: resource.resourceId, - href: resource.href, - } - } - } - const urlInfo = extractUrlInfo(text) - if (urlInfo) return { kind: 'url', text, href: urlInfo.href, domain: urlInfo.domain } - return { kind: 'text', text } + return resolveLinkKind(text, currentWorkspaceId) ?? { kind: 'text', text } } return { kind: 'text', text: stringifyValue(value) } } @@ -134,6 +126,45 @@ function stringifyValue(value: unknown): string { return JSON.stringify(value) } +/** Returns a `sim-resource` cell kind when `text` is a URL pointing to a + * resource in the current workspace, else null. Shared by plain string cells + * and workflow-output value cells so both surface in-workspace resource links + * as tagged chips. */ +function resolveSimResourceKind( + text: string, + currentWorkspaceId: string | undefined +): Extract | null { + if (!currentWorkspaceId) return null + const resource = extractSimResourceInfo(text) + if (!resource || resource.workspaceId !== currentWorkspaceId) return null + return { + kind: 'sim-resource', + workspaceId: resource.workspaceId, + resourceType: resource.resourceType, + resourceId: resource.resourceId, + href: resource.href, + } +} + +/** + * Promotes a cell value that is wholly a resource/URL string to a chip + * (in-workspace resource) or a favicon link, else null. Shared by plain string + * cells and workflow-output value cells. Workflow outputs apply this regardless + * of `column.type` (their type defaults to `json`, so gating on `string` would + * miss URL outputs); a stringified object never matches the whole-string URL + * check, so it stays JSON/text. + */ +function resolveLinkKind( + text: string, + currentWorkspaceId: string | undefined +): Extract | null { + const simKind = resolveSimResourceKind(text, currentWorkspaceId) + if (simKind) return simKind + const urlInfo = extractUrlInfo(text) + if (urlInfo) return { kind: 'url', text, href: urlInfo.href, domain: urlInfo.domain } + return null +} + const BARE_DOMAIN_RE = /^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/ function extractUrlInfo(text: string): { href: string; domain: string } | null { diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx index 466835d041..9eb5a8de8e 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx @@ -478,14 +478,14 @@ export function Table({ } /> )} - {/* Sort + filter render in both modes. In embedded (mothership) mode there's - no ResourceHeader, so the run/stop control rides in the options bar's - `extras` slot — keeping the bar populated whether or not a run is live. */} + {/* Sort + filter render in both modes (left-aligned). In embedded (mothership) + mode there's no ResourceHeader, so the run/stop control rides in the options + bar's right-aligned `trailing` slot — opposite the left-aligned filter/sort. */} setFilterOpen((prev) => !prev)} filterActive={filterOpen || !!queryOptions.filter} - extras={ + trailing={ embedded && selection.totalRunning > 0 ? ( Date: Sat, 30 May 2026 11:44:05 -0700 Subject: [PATCH 13/29] fix(icons): repair broken integration icon rendering (#4810) * fix(icons): repair broken integration icon rendering Two distinct bugs left integration icons broken on the /integrations page (visible at 32-40px, hidden at the toolbar's 16px): 1. Corrupted SVG paths (Notion, Greptile, Granola, Calendly, Grafana, Bedrock): over-minified data dropped elliptical-arc flag digits (e.g. `A1 1 0 5.9 7` instead of `A1 1 0 0 0 5.9 7`); Granola's cubic stream was truncated. Browsers abort path parsing at the first invalid arc flag, so each rendered as a fragment or blank. Replaced with correct path data from canonical sources, preserving each icon's existing fill/gradient and bgColor. 2. Invisible glyph (Bright Data): its icon uses fill='currentColor' but bgColor was '#FFFFFF', and every surface forces text-white on the glyph - white-on-white. Changed bgColor to Bright Data's brand blue (#3d7ffc) so the white glyph reads, matching the white-glyph-on-brand-chip convention. Co-Authored-By: Claude Opus 4.8 * fix(icons): restore Calendly dual-tone brand colors Addresses review feedback: the previous fix replaced the broken Calendly icon with a monochrome #006BFF path, dropping the cyan #0ae8f0 accent from the original dual-tone mark. Restored the two-tone logo (blue + cyan) using clean, valid path data, cropped to a tight square viewBox so it fills the chip. Co-Authored-By: Claude Opus 4.8 * improvement(icons): enlarge icons, fix Zoom contrast and Quiver chip - Zoom: glyph was blue-on-blue (#0B5CFF on #2D8CFF chip); switched to currentColor so it renders as a white glyph on the blue chip. - Quiver: chip bgColor #000000 -> #FFFFFF to match the icon's near-white box, and enlarged the mark slightly (viewBox crop). - Enlarged (tightened viewBox, verified no clipping): RevenueCat, Prospeo, Granola, Firecrawl, Enrich.so, and the AWS icons (RDS, DynamoDB, SQS, CloudFormation, Athena, CloudWatch, SES, Bedrock, S3). - ZoomInfo left unchanged: it is a full red rounded-square logo that already fills its frame, so a crop would clip it. Co-Authored-By: Claude Opus 4.8 * fix(icons): use Bright Data wordmark on white chip; repair Circleback - Bright Data: replaced the flame glyph with the official two-tone 'bright data' wordmark (provided asset), centered in a symmetric viewBox. Reverted the chip bgColor from #3d7ffc to #FFFFFF since the blue wordmark is invisible on a blue chip (the wordmark is designed for a light background). - Circleback: a minifier had rounded the pattern's image scale to scale(0), collapsing the embedded logo to zero size (invisible). Restored the correct scale (1/280 = 0.00357142857) so the C. mark renders. Co-Authored-By: Claude Opus 4.8 * fix(docs): sync Quiver block color card to white chip Reflects the Quiver bgColor change (#000000 -> #FFFFFF) in the docs block info card. Co-Authored-By: Claude Opus 4.8 * improvement(icons): enlarge AWS/Cloudflare/Dagster icons, fully white Zoom - Enlarged (tighter viewBox, render-verified, no clipping): Cloudflare, Dagster, and the red AWS icons AWS IAM, Identity Center, Secrets Manager, SES, STS. Identity Center was anomalously small (filled ~32% of its frame); the group is now sized consistently (~80% fill). - Zoom: the camera lens triangle was still #0B5CFF (blue-on-blue); switched it to currentColor so the whole camera renders white on the blue chip. Co-Authored-By: Claude Opus 4.8 * docs(wiza): consolidate individual reveal into a single operation Merges the separate Start/Get Individual Reveal operations into one Individual Reveal operation in the Wiza docs and integrations data (operationCount 5 -> 4). Co-Authored-By: Claude Opus 4.8 * improvement(icons): size remaining AWS icons to match the set (~80% fill) Bring RDS, DynamoDB, SQS, CloudFormation, Athena, CloudWatch and S3 up to the same ~80% fill as the AWS IAM/Identity Center/Secrets Manager/SES/STS group, so all AWS icons are visually consistent. Bedrock left as-is (already ~92% fill). Co-Authored-By: Claude Opus 4.8 * fix(icons): use Bright Data flame mark, enlarge ZoomInfo - Bright Data: the full 'bright data' wordmark was illegible at chip size. Replaced with just the flame-'i' brand mark (blue #4280f6 on the white chip), centered. - ZoomInfo: cropped the viewBox toward the white 'Zi' so it's larger; the red rounded-square background still fills the chip. Co-Authored-By: Claude Opus 4.8 * improvement(icons): enlarge CrowdStrike icon The falcon mark sat small in its chip because the icon used a wide 768x500 viewBox (letterboxed in the square chip). Switched to a square viewBox centered on the mark so it fills ~80%, consistent with the other icons. Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- apps/docs/components/icons.tsx | 185 ++++++++++-------- apps/docs/content/docs/en/tools/quiver.mdx | 2 +- apps/docs/content/docs/en/tools/wiza.mdx | 26 +-- .../integrations/data/integrations.json | 12 +- apps/sim/blocks/blocks/quiver.ts | 2 +- apps/sim/components/icons.tsx | 185 ++++++++++-------- 6 files changed, 215 insertions(+), 197 deletions(-) diff --git a/apps/docs/components/icons.tsx b/apps/docs/components/icons.tsx index 7985328c08..f21df07006 100644 --- a/apps/docs/components/icons.tsx +++ b/apps/docs/components/icons.tsx @@ -91,7 +91,12 @@ export function AgentPhoneIcon(props: SVGProps) { export function CrowdStrikeIcon(props: SVGProps) { return ( - + ) { export function FirecrawlIcon(props: SVGProps) { return ( - + ) { export function NotionIcon(props: SVGProps) { return ( - + @@ -1171,27 +1176,13 @@ export function GrafanaIcon(props: SVGProps) { const gradientId = `grafana_gradient_${id}` return ( - + - + @@ -1307,7 +1298,7 @@ export function GoogleSheetsIcon(props: SVGProps) { export const S3Icon = (props: SVGProps) => ( ) { {...props} width='1em' height='1em' - viewBox='0 0 32 32' + viewBox='1.17 1.178 29.66 29.643' fill='none' xmlns='http://www.w3.org/2000/svg' > @@ -2256,14 +2247,19 @@ export function BrandfetchIcon(props: SVGProps) { export function BrightDataIcon(props: SVGProps) { return ( - + ) @@ -3532,7 +3528,12 @@ export function QdrantIcon(props: SVGProps) { export function QuiverIcon(props: SVGProps) { return ( - + ) { export function CalendlyIcon(props: SVGProps) { return ( - - - - - - - - + + + + + ) } @@ -4735,8 +4746,8 @@ export function ZendeskIcon(props: SVGProps) { export function ZoomIcon(props: SVGProps) { return ( ) } @@ -4747,7 +4758,7 @@ export function ZoomInfoIcon(props: SVGProps) { return (
    From 97f7fe95dc6fd31bf0b7da5ead7797b17d9abe4e Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Sat, 30 May 2026 19:24:41 -0700 Subject: [PATCH 20/29] improvement(enrichments): limit company-info to fields both providers return (#4817) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hunter's company dataset returns null industry/foundedYear for many large companies (verified against the live API for Microsoft, Amazon, Google), so under the first-non-empty-wins cascade those columns appeared inconsistently across rows. Limit company-info outputs to employee count and description — the fields Hunter and PDL both reliably return — so every row is consistent. employeeCount is a string so Hunter's range bucket and PDL's exact count share the column. --- .../enrichments/company-info/company-info.ts | 58 ++++++++----------- 1 file changed, 25 insertions(+), 33 deletions(-) diff --git a/apps/sim/enrichments/company-info/company-info.ts b/apps/sim/enrichments/company-info/company-info.ts index 6f86d8ef75..b3fdc541ec 100644 --- a/apps/sim/enrichments/company-info/company-info.ts +++ b/apps/sim/enrichments/company-info/company-info.ts @@ -3,30 +3,41 @@ import { Building2 } from 'lucide-react' import { normalizeDomain, str, toolProvider } from '@/enrichments/providers' import type { EnrichmentConfig } from '@/enrichments/types' -/** Returns the value when it's a finite number, else `undefined`. */ -function num(value: unknown): number | undefined { - return typeof value === 'number' && Number.isFinite(value) ? value : undefined -} - /** - * Company Info enrichment. Looks up firmographics for a company domain, trying - * People Data Labs first (richest record, incl. employee count) then Hunter as - * a fallback. + * Company Info enrichment. Looks up a company by domain, trying Hunter first + * (free) then People Data Labs as a fallback. Outputs are limited to the fields + * both providers reliably return — employee count and description — so the + * result stays consistent regardless of which provider fills the cell. + * `employeeCount` is a string so Hunter's range bucket (e.g. `"11-50"`) and + * PDL's exact count map onto the same column. */ export const companyInfoEnrichment: EnrichmentConfig = { id: 'company-info', name: 'Company Info', - description: - "Look up a company's industry, size, founding year, and description from its domain.", + description: "Look up a company's size and description from its domain.", icon: Building2, inputs: [{ id: 'domain', name: 'Company domain', type: 'string', required: true }], outputs: [ - { id: 'industry', name: 'industry', type: 'string' }, - { id: 'employeeCount', name: 'employee count', type: 'number' }, - { id: 'foundedYear', name: 'founded year', type: 'number' }, + { id: 'employeeCount', name: 'employee count', type: 'string' }, { id: 'description', name: 'description', type: 'string' }, ], providers: [ + toolProvider({ + id: 'hunter', + label: 'Hunter', + toolId: 'hunter_companies_find', + buildParams: (inputs) => { + const domain = normalizeDomain(inputs.domain) + if (!domain) return null + return { domain } + }, + mapOutput: (output) => { + return filterUndefined({ + employeeCount: str(output.size) || undefined, + description: str(output.description) || undefined, + }) + }, + }), toolProvider({ id: 'pdl', label: 'People Data Labs', @@ -39,29 +50,10 @@ export const companyInfoEnrichment: EnrichmentConfig = { mapOutput: (output) => { const company = output.company as Record | undefined return filterUndefined({ - industry: str(company?.industry) || undefined, - employeeCount: num(company?.employee_count), - foundedYear: num(company?.founded), + employeeCount: str(company?.employee_count) || undefined, description: str(company?.summary) || undefined, }) }, }), - toolProvider({ - id: 'hunter', - label: 'Hunter', - toolId: 'hunter_companies_find', - buildParams: (inputs) => { - const domain = normalizeDomain(inputs.domain) - if (!domain) return null - return { domain } - }, - mapOutput: (output) => { - return filterUndefined({ - industry: str(output.industry) || undefined, - foundedYear: num(output.founded_year), - description: str(output.description) || undefined, - }) - }, - }), ], } From 6c476cf592b8d5053c44074e822fd88a49bb6563 Mon Sep 17 00:00:00 2001 From: Waleed Date: Sat, 30 May 2026 19:35:45 -0700 Subject: [PATCH 21/29] fix(files): don't reject external URLs containing '..' in file parse validation (#4821) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(files): don't reject external URLs containing '..' in file parse validation The file block's file_fetch operation rejected any external URL whose path contained '..' (e.g. Slack files-pri slugs with a literal '...') with 'Access denied: path traversal detected'. Traversal checks only apply to local paths — external http(s) URLs are fetched with SSRF protection downstream and are never resolved against the filesystem, so they now short-circuit as valid. Internal /api/files/serve/ URLs keep full traversal protection. * test(files): fix external-URL assertion to handle undefined error * test(files): assert success explicitly in external-URL traversal test * fix(files): keep traversal protection for https URLs matching internal serve paths --- apps/sim/app/api/files/parse/route.test.ts | 62 ++++++++++++++++++++++ apps/sim/app/api/files/parse/route.ts | 22 +++++++- 2 files changed, 83 insertions(+), 1 deletion(-) diff --git a/apps/sim/app/api/files/parse/route.test.ts b/apps/sim/app/api/files/parse/route.test.ts index b2ab510c9f..ccf9dd684a 100644 --- a/apps/sim/app/api/files/parse/route.test.ts +++ b/apps/sim/app/api/files/parse/route.test.ts @@ -796,6 +796,68 @@ describe('Files Parse API - Path Traversal Security', () => { } }) + it('should not treat .. inside external URLs as path traversal', async () => { + inputValidationMockFns.mockValidateUrlWithDNS.mockResolvedValue({ + isValid: true, + resolvedIP: '203.0.113.10', + }) + inputValidationMockFns.mockSecureFetchWithPinnedIP.mockResolvedValue( + new Response('slack file content', { + status: 200, + headers: { 'content-type': 'text/plain' }, + }) + ) + permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('write') + + // Slack truncates long titles with a literal ellipsis, so the slug contains `..` + const slackUrl = + 'https://files.slack.com/files-pri/T08-F0B/_other__no_invitation_messages_get_sent_-_sim_on_railway...txt' + + const request = new NextRequest('http://localhost:3000/api/files/parse', { + method: 'POST', + body: JSON.stringify({ filePath: slackUrl, workspaceId: 'workspace-id' }), + }) + + const response = await POST(request) + const result = await response.json() + + expect(result.success).toBe(true) + // The URL reaching the pinned fetch proves it passed validation and routed + // to external-URL handling rather than being rejected as a local path. + expect(inputValidationMockFns.mockSecureFetchWithPinnedIP).toHaveBeenCalledWith( + slackUrl, + '203.0.113.10', + expect.any(Object) + ) + }) + + it('should still reject traversal in https URLs that look like internal serve URLs', async () => { + inputValidationMockFns.mockValidateUrlWithDNS.mockResolvedValue({ + isValid: true, + resolvedIP: '203.0.113.10', + }) + inputValidationMockFns.mockSecureFetchWithPinnedIP.mockResolvedValue( + new Response('should never be fetched', { status: 200 }) + ) + + // Absolute https URL containing `/api/files/serve/` matches isInternalFileUrl and would + // route to handleCloudFile — so it must keep traversal protection, not be waved through + // as an external URL. + const request = new NextRequest('http://localhost:3000/api/files/parse', { + method: 'POST', + body: JSON.stringify({ + filePath: 'https://attacker.com/api/files/serve/../../../etc/passwd', + }), + }) + + const response = await POST(request) + const result = await response.json() + + expect(result.success).toBe(false) + expect(result.error).toMatch(/Access denied: path traversal detected/) + expect(inputValidationMockFns.mockSecureFetchWithPinnedIP).not.toHaveBeenCalled() + }) + it('should handle encoded path traversal attempts', async () => { const encodedMaliciousPaths = [ '/api/files/serve/%2e%2e%2f%2e%2e%2fetc%2fpasswd', // ../../../etc/passwd diff --git a/apps/sim/app/api/files/parse/route.ts b/apps/sim/app/api/files/parse/route.ts index ea4f493dd8..b925a36603 100644 --- a/apps/sim/app/api/files/parse/route.ts +++ b/apps/sim/app/api/files/parse/route.ts @@ -419,13 +419,33 @@ function assertParsedContentWithinLimit(content: string, maxBytes?: number): str } /** - * Validate file path for security - prevents null byte injection and path traversal attacks + * Validate file path for security - prevents null byte injection and path traversal attacks. + * + * External URLs (`http`/`https`) are fetched over HTTP — with SSRF protection applied + * downstream in `fetchExternalUrlToWorkspace` (DNS resolution + private/reserved IP blocking) + * — and are never resolved against the filesystem, so `..`/`~` are legal URL content and must + * not be rejected. Providers such as Slack routinely emit slugs containing a literal `...`. + * + * Internal file URLs (`/api/files/serve/...`) ARE resolved to storage keys and filesystem + * paths via `extractStorageKey`, so they keep full traversal protection. The external + * short-circuit explicitly excludes them: `parseFileSingle` routes anything matching + * `isInternalFileUrl` to `handleCloudFile` (even an absolute `https://host/api/files/serve/...`), + * so such inputs must stay subject to the `..`/`~` checks rather than being waved through as + * external URLs. Only the leading-`/` "outside allowed directory" check is relaxed for them, + * since that prefix is expected. */ function validateFilePath(filePath: string): { isValid: boolean; error?: string } { if (filePath.includes('\0')) { return { isValid: false, error: 'Invalid path: null byte detected' } } + if ( + (filePath.startsWith('http://') || filePath.startsWith('https://')) && + !isInternalFileUrl(filePath) + ) { + return { isValid: true } + } + if (filePath.includes('..')) { return { isValid: false, error: 'Access denied: path traversal detected' } } From f6685cf1b157f4a064d9ab23b6f912ac1fec4c8e Mon Sep 17 00:00:00 2001 From: Waleed Date: Sat, 30 May 2026 20:09:53 -0700 Subject: [PATCH 22/29] feat(google-sheets): add row filtering to read with numeric operators (#4822) * feat(google-sheets): add row filtering to read with numeric operators Adds client-side row filtering to the Google Sheets read (v2) operation. Filter the returned rows by a header column using text operators (contains, not_contains, exact, not_equals, starts_with, ends_with) and numeric/ordering operators (gt, gte, lt, lte). Filtering lives in a pure, unit-tested helper (filterSheetRows) and runs over the fetched read range; an optional `filter` output reports whether the column was found and how many rows matched. Also hardens the surrounding tools: - trim spreadsheetId in write/update/append URL builders (matches read) - URL-encode the v1 read default range - expose valueInputOption for the update operation in the block Backwards compatible: with no filter requested, read output is byte- identical and the `filter` field is omitted. The filterMatchType union is widened additively (4 -> 10 values). * fix(google-sheets): correct filter metadata for missing column and header-only sheets - matchedRows is now 0 (not totalRows) when the filter column is not found, so it no longer contradicts applied=false / columnFound=false - columnFound now reflects an actual header lookup for empty/header-only sheets instead of being hardcoded true - add tests covering header-only and empty sheets with present/absent columns --- apps/sim/blocks/blocks/google_sheets.ts | 22 +- apps/sim/tools/google_sheets/append.ts | 6 +- apps/sim/tools/google_sheets/filter.test.ts | 228 ++++++++++++++++++++ apps/sim/tools/google_sheets/filter.ts | 152 +++++++++++++ apps/sim/tools/google_sheets/read.test.ts | 97 +++++++++ apps/sim/tools/google_sheets/read.ts | 60 +++--- apps/sim/tools/google_sheets/types.ts | 19 +- apps/sim/tools/google_sheets/update.ts | 4 +- apps/sim/tools/google_sheets/write.ts | 4 +- 9 files changed, 548 insertions(+), 44 deletions(-) create mode 100644 apps/sim/tools/google_sheets/filter.test.ts create mode 100644 apps/sim/tools/google_sheets/filter.ts create mode 100644 apps/sim/tools/google_sheets/read.test.ts diff --git a/apps/sim/blocks/blocks/google_sheets.ts b/apps/sim/blocks/blocks/google_sheets.ts index ef61d86444..eef0c84d0a 100644 --- a/apps/sim/blocks/blocks/google_sheets.ts +++ b/apps/sim/blocks/blocks/google_sheets.ts @@ -462,9 +462,15 @@ Return ONLY the range string - no sheet name, no explanations, no quotes.`, type: 'dropdown', options: [ { label: 'Contains', id: 'contains' }, + { label: 'Does Not Contain', id: 'not_contains' }, { label: 'Exact Match', id: 'exact' }, + { label: 'Not Equal To', id: 'not_equals' }, { label: 'Starts With', id: 'starts_with' }, { label: 'Ends With', id: 'ends_with' }, + { label: 'Greater Than', id: 'gt' }, + { label: 'Greater Than or Equal', id: 'gte' }, + { label: 'Less Than', id: 'lt' }, + { label: 'Less Than or Equal', id: 'lte' }, ], condition: { field: 'operation', value: 'read' }, mode: 'advanced', @@ -503,7 +509,7 @@ Return ONLY the JSON array - no explanations, no markdown, no extra text.`, { label: 'User Entered (Parse formulas)', id: 'USER_ENTERED' }, { label: "Raw (Don't parse formulas)", id: 'RAW' }, ], - condition: { field: 'operation', value: ['write', 'batch_update'] }, + condition: { field: 'operation', value: ['write', 'update', 'batch_update'] }, }, // Update-specific Fields { @@ -896,11 +902,15 @@ Return ONLY the JSON array - no explanations, no markdown, no extra text.`, type: 'string', description: 'Destination spreadsheet ID for copy', }, - filterColumn: { type: 'string', description: 'Column header name to filter on' }, + filterColumn: { + type: 'string', + description: 'Column header name to filter the read rows on (within the read range)', + }, filterValue: { type: 'string', description: 'Value to match against the filter column' }, filterMatchType: { type: 'string', - description: 'Match type: contains, exact, starts_with, or ends_with', + description: + 'Match type: contains, not_contains, exact, not_equals, starts_with, ends_with, gt, gte, lt, or lte', }, }, outputs: { @@ -920,6 +930,12 @@ Return ONLY the JSON array - no explanations, no markdown, no extra text.`, description: 'Cell values as 2D array', condition: { field: 'operation', value: 'read' }, }, + filter: { + type: 'json', + description: + 'Filter summary (present only when a filter was requested): applied, column, matchType, columnFound, matchedRows, totalRows', + condition: { field: 'operation', value: 'read' }, + }, // Write/Update/Append outputs updatedRange: { type: 'string', diff --git a/apps/sim/tools/google_sheets/append.ts b/apps/sim/tools/google_sheets/append.ts index b3cdfb06b9..7f290a2ce3 100644 --- a/apps/sim/tools/google_sheets/append.ts +++ b/apps/sim/tools/google_sheets/append.ts @@ -69,14 +69,14 @@ export const appendTool: ToolConfig { + it('passes values through unchanged when no filter column is provided', () => { + const result = filterSheetRows(VALUES, {}) + expect(result.applied).toBe(false) + expect(result.values).toBe(VALUES) + expect(result.totalRows).toBe(3) + }) + + it('passes through when filterValue is empty', () => { + const result = filterSheetRows(VALUES, { filterColumn: 'Status', filterValue: '' }) + expect(result.applied).toBe(false) + expect(result.values).toBe(VALUES) + }) + + it('defaults to case-insensitive contains', () => { + const result = filterSheetRows(VALUES, { filterColumn: 'status', filterValue: 'active' }) + expect(result.applied).toBe(true) + expect(result.columnFound).toBe(true) + expect(result.values).toEqual([VALUES[0], VALUES[1], VALUES[3]]) + expect(result.matchedRows).toBe(2) + }) + + it('matches column names case-insensitively and trims whitespace', () => { + const result = filterSheetRows(VALUES, { + filterColumn: ' EMAIL ', + filterValue: 'example.com', + }) + expect(result.columnFound).toBe(true) + expect(result.matchedRows).toBe(2) + }) + + it('supports exact and not_equals', () => { + expect( + filterSheetRows(VALUES, { + filterColumn: 'Status', + filterValue: 'Active', + filterMatchType: 'exact', + }).matchedRows + ).toBe(2) + expect( + filterSheetRows(VALUES, { + filterColumn: 'Status', + filterValue: 'Active', + filterMatchType: 'not_equals', + }).matchedRows + ).toBe(1) + }) + + it('supports starts_with, ends_with, and not_contains', () => { + expect( + filterSheetRows(VALUES, { + filterColumn: 'Email', + filterValue: 'bob', + filterMatchType: 'starts_with', + }).matchedRows + ).toBe(1) + expect( + filterSheetRows(VALUES, { + filterColumn: 'Email', + filterValue: '.com', + filterMatchType: 'ends_with', + }).matchedRows + ).toBe(3) + expect( + filterSheetRows(VALUES, { + filterColumn: 'Email', + filterValue: 'example.com', + filterMatchType: 'not_contains', + }).matchedRows + ).toBe(1) + }) + + it('compares numerically for ordering operators (not substring)', () => { + const gt = filterSheetRows(VALUES, { + filterColumn: 'Score', + filterValue: '50', + filterMatchType: 'gt', + }) + expect(gt.matchedRows).toBe(1) + expect(gt.values).toEqual([VALUES[0], VALUES[1]]) + + expect( + filterSheetRows(VALUES, { + filterColumn: 'Score', + filterValue: '40', + filterMatchType: 'gte', + }).matchedRows + ).toBe(2) + expect( + filterSheetRows(VALUES, { + filterColumn: 'Score', + filterValue: '40', + filterMatchType: 'lt', + }).matchedRows + ).toBe(1) + expect( + filterSheetRows(VALUES, { + filterColumn: 'Score', + filterValue: '40', + filterMatchType: 'lte', + }).matchedRows + ).toBe(2) + }) + + it('orders negative numbers correctly', () => { + const temps: unknown[][] = [ + ['City', 'Temp'], + ['A', '-5'], + ['B', '0'], + ['C', '-12'], + ['D', '3'], + ] + expect( + filterSheetRows(temps, { filterColumn: 'Temp', filterValue: '-5', filterMatchType: 'gte' }) + .matchedRows + ).toBe(3) + expect( + filterSheetRows(temps, { filterColumn: 'Temp', filterValue: '0', filterMatchType: 'lt' }) + .matchedRows + ).toBe(2) + }) + + it('excludes blank and non-numeric cells from numeric comparisons', () => { + const scores: unknown[][] = [ + ['Name', 'Score'], + ['Alice', '90'], + ['Bob', ''], + ['Carol', 'N/A'], + ['Dan', '60'], + ] + const result = filterSheetRows(scores, { + filterColumn: 'Score', + filterValue: '50', + filterMatchType: 'gt', + }) + expect(result.matchedRows).toBe(2) + expect(result.values).toEqual([scores[0], scores[1], scores[4]]) + }) + + it('falls back to lexicographic ordering when values are not numeric (ISO dates)', () => { + const dated: unknown[][] = [ + ['Task', 'Due'], + ['A', '2026-01-15'], + ['B', '2026-03-01'], + ['C', '2025-12-31'], + ] + const result = filterSheetRows(dated, { + filterColumn: 'Due', + filterValue: '2026-01-01', + filterMatchType: 'gte', + }) + expect(result.matchedRows).toBe(2) + }) + + it('reports columnFound=false and leaves values unchanged when the column is missing', () => { + const result = filterSheetRows(VALUES, { + filterColumn: 'Nonexistent', + filterValue: 'x', + }) + expect(result.applied).toBe(false) + expect(result.columnFound).toBe(false) + expect(result.matchedRows).toBe(0) + expect(result.values).toBe(VALUES) + expect(result.totalRows).toBe(3) + }) + + it('handles a header-only sheet and reports the column as found when it exists', () => { + const headerOnly: unknown[][] = [['Name', 'Status']] + const result = filterSheetRows(headerOnly, { filterColumn: 'Status', filterValue: 'Active' }) + expect(result.applied).toBe(false) + expect(result.columnFound).toBe(true) + expect(result.matchedRows).toBe(0) + expect(result.totalRows).toBe(0) + expect(result.values).toBe(headerOnly) + }) + + it('reports columnFound=false for a header-only sheet when the column is absent', () => { + const headerOnly: unknown[][] = [['Name', 'Status']] + const result = filterSheetRows(headerOnly, { filterColumn: 'Nonexistent', filterValue: 'x' }) + expect(result.applied).toBe(false) + expect(result.columnFound).toBe(false) + expect(result.matchedRows).toBe(0) + expect(result.values).toBe(headerOnly) + }) + + it('reports columnFound=false for an empty values array', () => { + const empty: unknown[][] = [] + const result = filterSheetRows(empty, { filterColumn: 'Status', filterValue: 'Active' }) + expect(result.applied).toBe(false) + expect(result.columnFound).toBe(false) + expect(result.matchedRows).toBe(0) + expect(result.totalRows).toBe(0) + }) + + it('treats missing cells as empty strings', () => { + const sparse: unknown[][] = [['Name', 'Status'], ['Alice'], ['Bob', 'Active']] + const result = filterSheetRows(sparse, { + filterColumn: 'Status', + filterValue: 'Active', + filterMatchType: 'exact', + }) + expect(result.matchedRows).toBe(1) + expect(result.values).toEqual([sparse[0], sparse[2]]) + }) + + it('always retains the header row in filtered output', () => { + const result = filterSheetRows(VALUES, { + filterColumn: 'Status', + filterValue: 'no-match', + filterMatchType: 'exact', + }) + expect(result.values).toEqual([VALUES[0]]) + expect(result.matchedRows).toBe(0) + }) +}) diff --git a/apps/sim/tools/google_sheets/filter.ts b/apps/sim/tools/google_sheets/filter.ts new file mode 100644 index 0000000000..ea4caa460a --- /dev/null +++ b/apps/sim/tools/google_sheets/filter.ts @@ -0,0 +1,152 @@ +/** + * Client-side row filtering for Google Sheets read results. + * + * The Google Sheets REST API (`spreadsheets.values.get`) has no server-side + * content filtering — `DataFilter` selects only by A1 range, grid range, or + * developer metadata, never by cell value. Filtering by cell content must + * therefore happen after values are fetched, over the window of rows the read + * returned (e.g. the default `A1:Z1000`), not the entire sheet. + */ + +/** + * Supported ways to compare a cell against the filter value. Text operators are + * case-insensitive. The ordering operators (`gt`/`gte`/`lt`/`lte`) compare + * numerically when both operands parse as finite numbers, fall back to + * case-insensitive lexicographic comparison when both are non-numeric (which + * orders ISO dates correctly), and never match when one side is numeric and the + * other is not (the values are not comparable). + */ +export type SheetFilterMatchType = + | 'contains' + | 'not_contains' + | 'exact' + | 'not_equals' + | 'starts_with' + | 'ends_with' + | 'gt' + | 'gte' + | 'lt' + | 'lte' + +export interface SheetFilterOptions { + filterColumn?: string + filterValue?: string + filterMatchType?: SheetFilterMatchType +} + +export interface SheetFilterResult { + /** The (possibly filtered) values, always including the header row when present. */ + values: unknown[][] + /** Whether row filtering was actually applied to the data rows. */ + applied: boolean + /** Whether the requested filter column was found in the header row. */ + columnFound: boolean + /** Number of data rows (excluding the header) that matched the filter. */ + matchedRows: number + /** Total number of data rows (excluding the header) that were considered. */ + totalRows: number +} + +const DEFAULT_MATCH_TYPE: SheetFilterMatchType = 'contains' + +/** + * Parses a cell string as a finite number, or returns null when it is blank or + * non-numeric so callers can fall back to lexicographic comparison. + */ +function asFiniteNumber(value: string): number | null { + if (value.trim() === '') return null + const parsed = Number(value) + return Number.isFinite(parsed) ? parsed : null +} + +/** Case-insensitive lexicographic comparison returning -1, 0, or 1. */ +function compareLexicographic(cell: string, target: string): number { + return Math.sign(cell.toLowerCase().localeCompare(target.toLowerCase())) +} + +/** Evaluates a single cell against the filter target for the given match type. */ +function matchesCell(cell: string, target: string, matchType: SheetFilterMatchType): boolean { + switch (matchType) { + case 'gt': + case 'gte': + case 'lt': + case 'lte': { + const cellNum = asFiniteNumber(cell) + const targetNum = asFiniteNumber(target) + let cmp: number + if (cellNum !== null && targetNum !== null) { + cmp = Math.sign(cellNum - targetNum) + } else if (cellNum === null && targetNum === null) { + cmp = compareLexicographic(cell, target) + } else { + return false + } + if (matchType === 'gt') return cmp > 0 + if (matchType === 'gte') return cmp >= 0 + if (matchType === 'lt') return cmp < 0 + return cmp <= 0 + } + case 'exact': + return cell.toLowerCase() === target.toLowerCase() + case 'not_equals': + return cell.toLowerCase() !== target.toLowerCase() + case 'starts_with': + return cell.toLowerCase().startsWith(target.toLowerCase()) + case 'ends_with': + return cell.toLowerCase().endsWith(target.toLowerCase()) + case 'not_contains': + return !cell.toLowerCase().includes(target.toLowerCase()) + default: + return cell.toLowerCase().includes(target.toLowerCase()) + } +} + +/** + * Filters a 2D values array (header row + data rows) by matching a single column + * against a target value. Returns the original values untouched when no filter + * is requested, when there are no data rows, or when the column is not found — + * the `applied`/`columnFound` flags let callers distinguish "no match possible" + * from "everything matched". + */ +export function filterSheetRows( + values: unknown[][], + options: SheetFilterOptions +): SheetFilterResult { + const { filterColumn, filterValue, filterMatchType } = options + const totalRows = Math.max(values.length - 1, 0) + + if (!filterColumn || filterValue === undefined || filterValue === '') { + return { values, applied: false, columnFound: true, matchedRows: totalRows, totalRows } + } + + const headers = values[0] ?? [] + const normalizedColumn = filterColumn.trim().toLowerCase() + const columnIndex = headers.findIndex( + (header) => String(header).trim().toLowerCase() === normalizedColumn + ) + const columnFound = columnIndex !== -1 + + // No data rows to evaluate (empty or header-only sheet): nothing matched, but + // still report whether the requested column actually exists in the header. + if (values.length <= 1) { + return { values, applied: false, columnFound, matchedRows: 0, totalRows: 0 } + } + + // Column not found: leave rows untouched and report zero matches, not totalRows. + if (!columnFound) { + return { values, applied: false, columnFound: false, matchedRows: 0, totalRows } + } + + const matchType = filterMatchType ?? DEFAULT_MATCH_TYPE + const matched = values + .slice(1) + .filter((row) => matchesCell(String(row[columnIndex] ?? ''), filterValue, matchType)) + + return { + values: [values[0], ...matched], + applied: true, + columnFound: true, + matchedRows: matched.length, + totalRows, + } +} diff --git a/apps/sim/tools/google_sheets/read.test.ts b/apps/sim/tools/google_sheets/read.test.ts new file mode 100644 index 0000000000..829969bcdb --- /dev/null +++ b/apps/sim/tools/google_sheets/read.test.ts @@ -0,0 +1,97 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { readV2Tool } from '@/tools/google_sheets/read' +import type { GoogleSheetsV2ToolParams } from '@/tools/google_sheets/types' + +const SPREADSHEET_ID = 'abc123' +const URL = `https://sheets.googleapis.com/v4/spreadsheets/${SPREADSHEET_ID}/values/Sheet1!A1:Z1000` + +const SHEET_DATA = { + range: 'Sheet1!A1:D4', + values: [ + ['Name', 'Status'], + ['Alice', 'Active'], + ['Bob', 'Closed'], + ], +} + +function mockResponse(body: unknown, url = URL): Response { + // double-cast-allowed: lightweight Response stub for transformResponse unit test + return { url, json: async () => body } as unknown as Response +} + +const baseParams: GoogleSheetsV2ToolParams = { + accessToken: 'token', + spreadsheetId: SPREADSHEET_ID, + sheetName: 'Sheet1', +} + +describe('readV2Tool.transformResponse', () => { + it('returns values untouched and omits the filter field when no filter is requested', async () => { + const result = await readV2Tool.transformResponse!(mockResponse(SHEET_DATA), baseParams) + + expect(result.output.values).toEqual(SHEET_DATA.values) + expect('filter' in result.output).toBe(false) + expect(result.output.range).toBe(SHEET_DATA.range) + expect(result.output.metadata.spreadsheetId).toBe(SPREADSHEET_ID) + }) + + it('omits the filter field when filterColumn is set but filterValue is empty', async () => { + const result = await readV2Tool.transformResponse!(mockResponse(SHEET_DATA), { + ...baseParams, + filterColumn: 'Status', + filterValue: '', + }) + + expect('filter' in result.output).toBe(false) + expect(result.output.values).toEqual(SHEET_DATA.values) + }) + + it('filters rows and reports filter metadata when a filter is applied', async () => { + const result = await readV2Tool.transformResponse!(mockResponse(SHEET_DATA), { + ...baseParams, + filterColumn: 'Status', + filterValue: 'Active', + filterMatchType: 'exact', + }) + + expect(result.output.values).toEqual([ + ['Name', 'Status'], + ['Alice', 'Active'], + ]) + expect(result.output.filter).toEqual({ + applied: true, + column: 'Status', + matchType: 'exact', + columnFound: true, + matchedRows: 1, + totalRows: 2, + }) + }) + + it('leaves values unchanged and reports columnFound=false when the column is missing', async () => { + const result = await readV2Tool.transformResponse!(mockResponse(SHEET_DATA), { + ...baseParams, + filterColumn: 'Nonexistent', + filterValue: 'x', + }) + + expect(result.output.values).toEqual(SHEET_DATA.values) + expect(result.output.filter?.columnFound).toBe(false) + expect(result.output.filter?.applied).toBe(false) + expect(result.output.filter?.matchedRows).toBe(0) + }) + + it('handles a response with no values array', async () => { + const result = await readV2Tool.transformResponse!(mockResponse({ range: 'Sheet1!A1' }), { + ...baseParams, + filterColumn: 'Status', + filterValue: 'Active', + }) + + expect(result.output.values).toEqual([]) + expect(result.output.filter?.applied).toBe(false) + }) +}) diff --git a/apps/sim/tools/google_sheets/read.ts b/apps/sim/tools/google_sheets/read.ts index 7494884727..c3b8bfa994 100644 --- a/apps/sim/tools/google_sheets/read.ts +++ b/apps/sim/tools/google_sheets/read.ts @@ -1,3 +1,4 @@ +import { filterSheetRows } from '@/tools/google_sheets/filter' import type { GoogleSheetsReadResponse, GoogleSheetsToolParams, @@ -53,7 +54,7 @@ export const readTool: ToolConfig 1) { - const headers = values[0] as string[] - const columnIndex = headers.findIndex( - (h) => String(h).toLowerCase() === params.filterColumn!.toLowerCase() - ) + const filterRequested = + Boolean(params?.filterColumn) && + params?.filterValue !== undefined && + params?.filterValue !== '' - if (columnIndex !== -1) { - const matchType = params.filterMatchType ?? 'contains' - const filterVal = params.filterValue.toLowerCase() - - const filteredRows = values.slice(1).filter((row) => { - const cellValue = String(row[columnIndex] ?? '').toLowerCase() - switch (matchType) { - case 'exact': - return cellValue === filterVal - case 'starts_with': - return cellValue.startsWith(filterVal) - case 'ends_with': - return cellValue.endsWith(filterVal) - default: - return cellValue.includes(filterVal) - } - }) - - // Return header row + matching rows - values = [values[0], ...filteredRows] - } - } + const filterResult = filterSheetRows(rawValues, { + filterColumn: params?.filterColumn, + filterValue: params?.filterValue, + filterMatchType: params?.filterMatchType, + }) return { success: true, output: { sheetName: params?.sheetName ?? '', range: data.range ?? '', - values, + values: filterResult.values, metadata: { spreadsheetId: metadata.spreadsheetId, spreadsheetUrl: metadata.spreadsheetUrl, }, + ...(filterRequested + ? { + filter: { + applied: filterResult.applied, + column: params?.filterColumn ?? '', + matchType: params?.filterMatchType ?? 'contains', + columnFound: filterResult.columnFound, + matchedRows: filterResult.matchedRows, + totalRows: filterResult.totalRows, + }, + } + : {}), }, } }, diff --git a/apps/sim/tools/google_sheets/types.ts b/apps/sim/tools/google_sheets/types.ts index 8379d78ebe..119a71e305 100644 --- a/apps/sim/tools/google_sheets/types.ts +++ b/apps/sim/tools/google_sheets/types.ts @@ -1,6 +1,7 @@ import type { GoogleSheetsV2DeleteRowsResponse } from '@/tools/google_sheets/delete_rows' import type { GoogleSheetsV2DeleteSheetResponse } from '@/tools/google_sheets/delete_sheet' import type { GoogleSheetsV2DeleteSpreadsheetResponse } from '@/tools/google_sheets/delete_spreadsheet' +import type { SheetFilterMatchType } from '@/tools/google_sheets/filter' import type { ToolResponse } from '@/tools/types' interface GoogleSheetsRange { @@ -81,12 +82,28 @@ export type GoogleSheetsResponse = // V2 Types - with explicit sheetName parameter +export interface GoogleSheetsFilterInfo { + /** Whether row filtering was actually applied to the data rows. */ + applied: boolean + /** The column header the filter targeted. */ + column: string + /** The match type used for the comparison. */ + matchType: SheetFilterMatchType + /** Whether the requested filter column was found in the header row. */ + columnFound: boolean + /** Number of data rows (excluding the header) that matched the filter. */ + matchedRows: number + /** Total number of data rows (excluding the header) that were considered. */ + totalRows: number +} + export interface GoogleSheetsV2ReadResponse extends ToolResponse { output: { sheetName: string range: string values: any[][] metadata: GoogleSheetsMetadata + filter?: GoogleSheetsFilterInfo } } @@ -134,7 +151,7 @@ export interface GoogleSheetsV2ToolParams { majorDimension?: 'ROWS' | 'COLUMNS' filterColumn?: string filterValue?: string - filterMatchType?: 'contains' | 'exact' | 'starts_with' | 'ends_with' + filterMatchType?: SheetFilterMatchType } export type GoogleSheetsV2Response = diff --git a/apps/sim/tools/google_sheets/update.ts b/apps/sim/tools/google_sheets/update.ts index fb088c60c7..f6900e2bc8 100644 --- a/apps/sim/tools/google_sheets/update.ts +++ b/apps/sim/tools/google_sheets/update.ts @@ -63,7 +63,7 @@ export const updateTool: ToolConfig Date: Sat, 30 May 2026 20:55:16 -0700 Subject: [PATCH 23/29] fix(selectors): fetch all pages for paginated dropdown list routes (#4823) * fix(selectors): fetch all pages for paginated dropdown list routes Dropdown selectors fetched only the first page of paginated provider APIs, silently hiding results past page one. Add bounded server-side draining to the list routes across Microsoft Graph, Google, Notion, Atlassian, Linear, AWS CloudWatch, and offset/token REST APIs, plus a shared client-side drain cap in the selector hook. Response shapes, stored values, and tool execution are unchanged; CloudWatch list tools still honor a caller-supplied limit. Also fixes the Word file picker that was searching for .xlsx files. * fix(selectors): harden JSM and Monday pagination draining - JSM service-desk/request-type drains advance `start` by the actual row count returned (not the fixed page size) and stop on an empty page, so a short non-final page can't skip items. - Monday boards drain now checks `response.ok` per page, surfacing a mid-drain HTTP failure instead of treating it as an empty final page and returning a partial 200. * docs(selectors): clarify JSM drain advances start by actual row count The offset-advancement fix (advance `start` by the rows returned, not the fixed page size) landed in 7b19788a8; update the TSDoc to match so it no longer reads as advancing by `limit`. * fix(selectors): drain fetchPage in direct fetchList callers Making `fetchList` optional left three direct callers (outside the useSelectorOptions hook) calling it unguarded, which broke the build's type check. Route them through a shared `loadAllSelectorOptions` helper that uses `fetchList` when present and otherwise drains `fetchPage`. This also prevents a regression: `confluence.spaces` / `knowledge.documents` now paginate via `fetchPage` only, and these callers (search/replace, value resolution) would otherwise have silently returned no options. * chore(selectors): rename MAX_PAGE_PAGES to MAX_NOTION_PAGES for readability --- .../api/auth/oauth/microsoft/files/route.ts | 130 +++++++++++++----- .../sim/app/api/tools/airtable/bases/route.ts | 100 +++++++++++--- .../app/api/tools/asana/workspaces/route.ts | 110 ++++++++++++--- .../cloudwatch/describe-log-groups/route.ts | 66 +++++++-- apps/sim/app/api/tools/cloudwatch/utils.ts | 94 +++++++++---- apps/sim/app/api/tools/drive/files/route.ts | 77 +++++++---- .../tools/google_bigquery/datasets/route.ts | 81 ++++++----- .../api/tools/google_bigquery/tables/route.ts | 78 +++++++---- .../tools/google_calendar/calendars/route.ts | 74 ++++++---- .../tools/google_tasks/task-lists/route.ts | 61 +++++--- apps/sim/app/api/tools/jira/projects/route.ts | 108 ++++++++++++--- .../tools/jsm/selector-requesttypes/route.ts | 94 +++++++++++-- .../tools/jsm/selector-servicedesks/route.ts | 93 +++++++++++-- .../app/api/tools/linear/projects/route.ts | 50 ++++++- apps/sim/app/api/tools/linear/teams/route.ts | 46 ++++++- .../tools/microsoft-teams/channels/route.ts | 81 +++++++---- .../api/tools/microsoft-teams/chats/route.ts | 91 ++++++++---- .../api/tools/microsoft-teams/teams/route.ts | 87 ++++++++---- .../api/tools/microsoft_excel/drives/route.ts | 54 ++++++-- .../tools/microsoft_planner/plans/route.ts | 55 +++++--- .../tools/microsoft_planner/tasks/route.ts | 53 ++++--- apps/sim/app/api/tools/monday/boards/route.ts | 101 ++++++++++---- .../app/api/tools/notion/databases/route.ts | 81 +++++++---- apps/sim/app/api/tools/notion/pages/route.ts | 81 +++++++---- .../sim/app/api/tools/onedrive/files/route.ts | 69 +++++++--- .../app/api/tools/onedrive/folders/route.ts | 57 ++++++-- .../app/api/tools/outlook/folders/route.ts | 76 ++++++---- .../api/tools/pipedrive/pipelines/route.ts | 115 +++++++++++++--- .../app/api/tools/sharepoint/lists/route.ts | 54 ++++++-- .../app/api/tools/sharepoint/sites/route.ts | 54 ++++++-- apps/sim/app/api/tools/slack/users/route.ts | 75 +++++++--- .../app/api/tools/wealthbox/items/route.ts | 119 ++++++++++------ apps/sim/app/api/tools/webflow/items/route.ts | 109 +++++++++++---- apps/sim/app/api/tools/zoom/meetings/route.ts | 79 ++++++++--- .../hooks/queries/workflow-search-replace.ts | 10 +- .../providers/confluence/selectors.ts | 40 ++---- .../providers/knowledge/selectors.ts | 26 +++- .../providers/microsoft/selectors.ts | 2 + apps/sim/hooks/selectors/registry.test.ts | 4 +- apps/sim/hooks/selectors/registry.ts | 39 +++++- apps/sim/hooks/selectors/types.ts | 7 +- .../sim/hooks/selectors/use-selector-query.ts | 45 +++++- .../lib/api/contracts/selectors/knowledge.ts | 1 + .../lib/api/contracts/selectors/microsoft.ts | 7 + apps/sim/lib/oauth/google-pagination.ts | 108 +++++++++++++++ .../workflows/comparison/resolve-values.ts | 4 +- 46 files changed, 2309 insertions(+), 737 deletions(-) create mode 100644 apps/sim/lib/oauth/google-pagination.ts diff --git a/apps/sim/app/api/auth/oauth/microsoft/files/route.ts b/apps/sim/app/api/auth/oauth/microsoft/files/route.ts index 35858ecd13..7dcd342d66 100644 --- a/apps/sim/app/api/auth/oauth/microsoft/files/route.ts +++ b/apps/sim/app/api/auth/oauth/microsoft/files/route.ts @@ -8,13 +8,57 @@ import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getCredential, refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' import { GRAPH_ID_PATTERN } from '@/tools/microsoft_excel/utils' +import { assertGraphNextPageUrl, getGraphNextPageUrl } from '@/tools/sharepoint/utils' export const dynamic = 'force-dynamic' const logger = createLogger('MicrosoftFilesAPI') /** - * Get Excel files from Microsoft OneDrive + * Microsoft Graph paginates `search()` results via the `@odata.nextLink` + * absolute URL in the response body. Request the largest page (`$top` caps at + * 999) and drain following nextLink, bounded by a page cap. + * See https://learn.microsoft.com/en-us/graph/paging + */ +const MICROSOFT_FILES_PAGE_SIZE = 999 +const MAX_MICROSOFT_FILES_PAGES = 20 + +interface MicrosoftGraphFile { + id: string + name?: string + mimeType?: string + webUrl?: string + size?: number + createdDateTime?: string + lastModifiedDateTime?: string + thumbnails?: Array<{ small?: { url?: string }; medium?: { url?: string } }> + createdBy?: { user?: { displayName?: string; email?: string } } +} + +/** + * The shared `/api/auth/oauth/microsoft/files` route serves both the + * `microsoft.excel` and `microsoft.word` selectors. The two are distinguished + * by the `fileType` query parameter the selector forwards (defaulting to + * `excel` for backward compatibility), which drives both the search-query + * extension hint and the server-side result filter. + */ +const FILE_TYPE_CONFIG = { + excel: { + extension: '.xlsx', + mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + }, + word: { + extension: '.docx', + mimeType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + }, +} as const + +type MicrosoftFileType = keyof typeof FILE_TYPE_CONFIG + +/** + * Get Excel or Word files from Microsoft OneDrive / SharePoint. The + * `fileType` query parameter selects which Office document type to return + * (defaults to `excel`). */ export const GET = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() @@ -27,6 +71,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { query: searchParams.get('query') ?? undefined, driveId: searchParams.get('driveId') ?? undefined, workflowId: searchParams.get('workflowId') ?? undefined, + fileType: searchParams.get('fileType') ?? undefined, }) if (!parsedQuery.success) { @@ -40,6 +85,9 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const { credentialId, driveId, workflowId } = parsedQuery.data const query = parsedQuery.data.query ?? '' + const fileType: MicrosoftFileType = parsedQuery.data.fileType ?? 'excel' + const { extension, mimeType: targetMimeType } = FILE_TYPE_CONFIG[fileType] + const authz = await authorizeCredentialUse(request, { credentialId, workflowId, @@ -72,11 +120,8 @@ export const GET = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 }) } - // Build search query for Excel files - let searchQuery = '.xlsx' - if (query) { - searchQuery = `${query} .xlsx` - } + // Build search query for the requested Office document type + const searchQuery = query ? `${query} ${extension}` : extension // Build the query parameters for Microsoft Graph API const searchParams_new = new URLSearchParams() @@ -84,7 +129,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { '$select', 'id,name,mimeType,webUrl,thumbnails,createdDateTime,lastModifiedDateTime,size,createdBy' ) - searchParams_new.append('$top', '50') + searchParams_new.append('$top', String(MICROSOFT_FILES_PAGE_SIZE)) // When driveId is provided (SharePoint), search within that specific drive. // Otherwise, search the user's personal OneDrive. @@ -99,44 +144,57 @@ export const GET = withRouteHandler(async (request: NextRequest) => { } const drivePath = driveId ? `drives/${driveId}` : 'me/drive' - const response = await fetch( - `https://graph.microsoft.com/v1.0/${drivePath}/root/search(q='${encodeURIComponent(searchQuery)}')?${searchParams_new.toString()}`, - { + const rawFiles: MicrosoftGraphFile[] = [] + let nextUrl: string | undefined = + `https://graph.microsoft.com/v1.0/${drivePath}/root/search(q='${encodeURIComponent(searchQuery)}')?${searchParams_new.toString()}` + + for (let page = 0; page < MAX_MICROSOFT_FILES_PAGES && nextUrl; page++) { + const response = await fetch(nextUrl, { headers: { Authorization: `Bearer ${accessToken}`, }, - } - ) - - if (!response.ok) { - const errorData = await response.json().catch(() => ({ error: { message: 'Unknown error' } })) - logger.error(`[${requestId}] Microsoft Graph API error`, { - status: response.status, - error: errorData.error?.message || 'Failed to fetch Excel files from Microsoft OneDrive', }) - return NextResponse.json( - { - error: errorData.error?.message || 'Failed to fetch Excel files from Microsoft OneDrive', - }, - { status: response.status } - ) + + if (!response.ok) { + const errorData = await response + .json() + .catch(() => ({ error: { message: 'Unknown error' } })) + logger.error(`[${requestId}] Microsoft Graph API error`, { + status: response.status, + error: errorData.error?.message || 'Failed to fetch files from Microsoft OneDrive', + }) + return NextResponse.json( + { + error: errorData.error?.message || 'Failed to fetch files from Microsoft OneDrive', + }, + { status: response.status } + ) + } + + const data = await response.json() + rawFiles.push(...((data.value as MicrosoftGraphFile[]) || [])) + + const nextLink = getGraphNextPageUrl(data) + nextUrl = nextLink ? assertGraphNextPageUrl(nextLink) : undefined + + if (nextUrl && page === MAX_MICROSOFT_FILES_PAGES - 1) { + logger.warn( + `[${requestId}] Microsoft files search hit pagination cap; list may be incomplete`, + { fileType, pages: MAX_MICROSOFT_FILES_PAGES, collected: rawFiles.length } + ) + } } - const data = await response.json() - let files = data.value || [] - - // Transform Microsoft Graph response to match expected format and filter for Excel files - files = files + // Transform Microsoft Graph response and filter to the requested file type + const files = rawFiles .filter( - (file: any) => - file.name?.toLowerCase().endsWith('.xlsx') || - file.mimeType === 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' + (file: MicrosoftGraphFile) => + file.name?.toLowerCase().endsWith(extension) || file.mimeType === targetMimeType ) - .map((file: any) => ({ + .map((file: MicrosoftGraphFile) => ({ id: file.id, name: file.name, - mimeType: - file.mimeType || 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + mimeType: file.mimeType || targetMimeType, iconLink: file.thumbnails?.[0]?.small?.url, webViewLink: file.webUrl, thumbnailLink: file.thumbnails?.[0]?.medium?.url, @@ -155,7 +213,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ files }, { status: 200 }) } catch (error) { - logger.error(`[${requestId}] Error fetching Excel files from Microsoft OneDrive`, error) + logger.error(`[${requestId}] Error fetching files from Microsoft OneDrive`, error) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } }) diff --git a/apps/sim/app/api/tools/airtable/bases/route.ts b/apps/sim/app/api/tools/airtable/bases/route.ts index b2545df0e8..20b3b3459d 100644 --- a/apps/sim/app/api/tools/airtable/bases/route.ts +++ b/apps/sim/app/api/tools/airtable/bases/route.ts @@ -11,6 +11,71 @@ const logger = createLogger('AirtableBasesAPI') export const dynamic = 'force-dynamic' +const AIRTABLE_MAX_BASES_PAGES = 50 + +interface AirtableBase { + id: string + name: string +} + +/** + * Lists all Airtable bases, following the `offset` continuation token the Meta + * API returns (an opaque string, passed back verbatim as `?offset=`) so the + * full set is returned. Bounded by `AIRTABLE_MAX_BASES_PAGES`; logs a warning + * rather than silently dropping bases when the cap is hit. + */ +async function fetchAllBases(accessToken: string): Promise { + const bases: AirtableBase[] = [] + let offset: string | undefined + + for (let page = 0; page < AIRTABLE_MAX_BASES_PAGES; page++) { + const url = new URL('https://api.airtable.com/v0/meta/bases') + if (offset) { + url.searchParams.set('offset', offset) + } + + const response = await fetch(url.toString(), { + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + }) + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})) + throw new AirtableFetchError(response.status, errorData) + } + + const data = (await response.json()) as { bases?: AirtableBase[]; offset?: string } + if (Array.isArray(data.bases)) { + bases.push(...data.bases) + } + + offset = data.offset || undefined + if (!offset) { + return bases + } + + if (page === AIRTABLE_MAX_BASES_PAGES - 1) { + logger.warn('Airtable bases listing hit pagination cap; base list may be incomplete', { + pages: AIRTABLE_MAX_BASES_PAGES, + }) + } + } + + return bases +} + +class AirtableFetchError extends Error { + constructor( + readonly status: number, + readonly details: unknown + ) { + super('Failed to fetch Airtable bases') + this.name = 'AirtableFetchError' + } +} + export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() try { @@ -45,27 +110,24 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } - const response = await fetch('https://api.airtable.com/v0/meta/bases', { - headers: { - Authorization: `Bearer ${accessToken}`, - 'Content-Type': 'application/json', - }, - }) - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})) - logger.error('Failed to fetch Airtable bases', { - status: response.status, - error: errorData, - }) - return NextResponse.json( - { error: 'Failed to fetch Airtable bases', details: errorData }, - { status: response.status } - ) + let allBases: AirtableBase[] + try { + allBases = await fetchAllBases(accessToken) + } catch (error) { + if (error instanceof AirtableFetchError) { + logger.error('Failed to fetch Airtable bases', { + status: error.status, + error: error.details, + }) + return NextResponse.json( + { error: 'Failed to fetch Airtable bases', details: error.details }, + { status: error.status } + ) + } + throw error } - const data = await response.json() - const bases = (data.bases || []).map((base: { id: string; name: string }) => ({ + const bases = allBases.map((base) => ({ id: base.id, name: base.name, })) diff --git a/apps/sim/app/api/tools/asana/workspaces/route.ts b/apps/sim/app/api/tools/asana/workspaces/route.ts index ee66783722..0f71376c6d 100644 --- a/apps/sim/app/api/tools/asana/workspaces/route.ts +++ b/apps/sim/app/api/tools/asana/workspaces/route.ts @@ -11,6 +11,81 @@ const logger = createLogger('AsanaWorkspacesAPI') export const dynamic = 'force-dynamic' +const ASANA_PAGE_LIMIT = 100 +const ASANA_MAX_WORKSPACES_PAGES = 50 + +interface AsanaWorkspace { + gid: string + name: string +} + +interface AsanaWorkspacesPage { + data?: AsanaWorkspace[] + next_page?: { + offset?: string + } | null +} + +/** + * Lists all Asana workspaces using `limit`/`offset` pagination, following + * `next_page.offset` (an opaque token, passed back verbatim as `?offset=`) + * until `next_page` is null so the full set is returned. Bounded by + * `ASANA_MAX_WORKSPACES_PAGES`; logs a warning rather than silently dropping + * workspaces when the cap is hit. + */ +async function fetchAllWorkspaces(accessToken: string): Promise { + const workspaces: AsanaWorkspace[] = [] + let offset: string | undefined + + for (let page = 0; page < ASANA_MAX_WORKSPACES_PAGES; page++) { + const url = new URL('https://app.asana.com/api/1.0/workspaces') + url.searchParams.set('limit', String(ASANA_PAGE_LIMIT)) + if (offset) { + url.searchParams.set('offset', offset) + } + + const response = await fetch(url.toString(), { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/json', + }, + }) + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})) + throw new AsanaFetchError(response.status, errorData) + } + + const data = (await response.json()) as AsanaWorkspacesPage + if (Array.isArray(data.data)) { + workspaces.push(...data.data) + } + + offset = data.next_page?.offset || undefined + if (!offset) { + return workspaces + } + + if (page === ASANA_MAX_WORKSPACES_PAGES - 1) { + logger.warn('Asana workspaces listing hit pagination cap; workspace list may be incomplete', { + pages: ASANA_MAX_WORKSPACES_PAGES, + }) + } + } + + return workspaces +} + +class AsanaFetchError extends Error { + constructor( + readonly status: number, + readonly details: unknown + ) { + super('Failed to fetch Asana workspaces') + this.name = 'AsanaFetchError' + } +} + export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() try { @@ -42,27 +117,24 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } - const response = await fetch('https://app.asana.com/api/1.0/workspaces', { - headers: { - Authorization: `Bearer ${accessToken}`, - Accept: 'application/json', - }, - }) - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})) - logger.error('Failed to fetch Asana workspaces', { - status: response.status, - error: errorData, - }) - return NextResponse.json( - { error: 'Failed to fetch Asana workspaces', details: errorData }, - { status: response.status } - ) + let allWorkspaces: AsanaWorkspace[] + try { + allWorkspaces = await fetchAllWorkspaces(accessToken) + } catch (error) { + if (error instanceof AsanaFetchError) { + logger.error('Failed to fetch Asana workspaces', { + status: error.status, + error: error.details, + }) + return NextResponse.json( + { error: 'Failed to fetch Asana workspaces', details: error.details }, + { status: error.status } + ) + } + throw error } - const data = await response.json() - const workspaces = (data.data || []).map((workspace: { gid: string; name: string }) => ({ + const workspaces = allWorkspaces.map((workspace) => ({ id: workspace.gid, name: workspace.name, })) diff --git a/apps/sim/app/api/tools/cloudwatch/describe-log-groups/route.ts b/apps/sim/app/api/tools/cloudwatch/describe-log-groups/route.ts index bb0bff4390..4a2aabea1c 100644 --- a/apps/sim/app/api/tools/cloudwatch/describe-log-groups/route.ts +++ b/apps/sim/app/api/tools/cloudwatch/describe-log-groups/route.ts @@ -10,6 +10,12 @@ import { createCloudWatchLogsClient } from '@/app/api/tools/cloudwatch/utils' const logger = createLogger('CloudWatchDescribeLogGroups') +/** AWS DescribeLogGroups caps `limit` at 50 items per page. */ +const LOG_GROUPS_PAGE_SIZE = 50 + +/** Upper bound on pages drained to avoid unbounded loops on very large accounts. */ +const MAX_LOG_GROUPS_PAGES = 20 + export const POST = withRouteHandler(async (request: NextRequest) => { try { const auth = await checkSessionOrInternalAuth(request) @@ -33,26 +39,58 @@ export const POST = withRouteHandler(async (request: NextRequest) => { }) try { - const command = new DescribeLogGroupsCommand({ - ...(validatedData.prefix && { logGroupNamePrefix: validatedData.prefix }), - ...(validatedData.limit !== undefined && { limit: validatedData.limit }), - }) + const totalLimit = validatedData.limit + const logGroups: { + logGroupName: string + arn: string + storedBytes: number + retentionInDays: number | undefined + creationTime: number | undefined + }[] = [] + let nextToken: string | undefined - const response = await client.send(command) + for (let page = 0; page < MAX_LOG_GROUPS_PAGES; page++) { + const pageLimit = + totalLimit !== undefined + ? Math.min(LOG_GROUPS_PAGE_SIZE, totalLimit - logGroups.length) + : LOG_GROUPS_PAGE_SIZE - const logGroups = (response.logGroups ?? []).map((lg) => ({ - logGroupName: lg.logGroupName ?? '', - arn: lg.arn ?? '', - storedBytes: lg.storedBytes ?? 0, - retentionInDays: lg.retentionInDays, - creationTime: lg.creationTime, - })) + const command = new DescribeLogGroupsCommand({ + ...(validatedData.prefix && { logGroupNamePrefix: validatedData.prefix }), + limit: pageLimit, + ...(nextToken && { nextToken }), + }) - logger.info(`Successfully described ${logGroups.length} log groups`) + const response = await client.send(command) + + for (const lg of response.logGroups ?? []) { + logGroups.push({ + logGroupName: lg.logGroupName ?? '', + arn: lg.arn ?? '', + storedBytes: lg.storedBytes ?? 0, + retentionInDays: lg.retentionInDays, + creationTime: lg.creationTime, + }) + } + + nextToken = response.nextToken + if (!nextToken) break + if (totalLimit !== undefined && logGroups.length >= totalLimit) break + + if (page === MAX_LOG_GROUPS_PAGES - 1) { + logger.warn( + `DescribeLogGroups hit pagination cap of ${MAX_LOG_GROUPS_PAGES} pages; log group list may be incomplete` + ) + } + } + + const cappedLogGroups = totalLimit !== undefined ? logGroups.slice(0, totalLimit) : logGroups + + logger.info(`Successfully described ${cappedLogGroups.length} log groups`) return NextResponse.json({ success: true, - output: { logGroups }, + output: { logGroups: cappedLogGroups }, }) } finally { client.destroy() diff --git a/apps/sim/app/api/tools/cloudwatch/utils.ts b/apps/sim/app/api/tools/cloudwatch/utils.ts index 966aa67b2b..2984229e75 100644 --- a/apps/sim/app/api/tools/cloudwatch/utils.ts +++ b/apps/sim/app/api/tools/cloudwatch/utils.ts @@ -5,6 +5,7 @@ import { GetQueryResultsCommand, type ResultField, } from '@aws-sdk/client-cloudwatch-logs' +import { createLogger } from '@sim/logger' import { sleep } from '@sim/utils/helpers' import { DEFAULT_EXECUTION_TIMEOUT_MS } from '@/lib/core/execution-limits' @@ -96,37 +97,82 @@ export async function pollQueryResults( } } +/** AWS DescribeLogStreams caps `limit` at 50 items per page. */ +const LOG_STREAMS_PAGE_SIZE = 50 + +/** Upper bound on pages drained to avoid unbounded loops on log groups with many streams. */ +const MAX_LOG_STREAMS_PAGES = 20 + +const logger = createLogger('CloudWatchUtils') + +interface DescribedLogStream { + logStreamName: string + lastEventTimestamp: number | undefined + firstEventTimestamp: number | undefined + creationTime: number | undefined + storedBytes: number +} + +/** + * Lists log streams for a log group, following `nextToken` so the complete set + * is returned rather than just the first page. Bounded by + * `MAX_LOG_STREAMS_PAGES`; logs a warning rather than silently dropping streams + * when the cap is hit. Ordering/prefix inputs are preserved across all pages. + * + * When `limit` is provided it is treated as a total result cap: draining stops + * once enough streams have been collected. When omitted, every page is drained. + */ export async function describeLogStreams( client: CloudWatchLogsClient, logGroupName: string, options?: { prefix?: string; limit?: number } -): Promise<{ - logStreams: { - logStreamName: string - lastEventTimestamp: number | undefined - firstEventTimestamp: number | undefined - creationTime: number | undefined - storedBytes: number - }[] -}> { +): Promise<{ logStreams: DescribedLogStream[] }> { const hasPrefix = Boolean(options?.prefix) - const command = new DescribeLogStreamsCommand({ - logGroupName, - ...(hasPrefix - ? { orderBy: 'LogStreamName', logStreamNamePrefix: options!.prefix } - : { orderBy: 'LastEventTime', descending: true }), - ...(options?.limit !== undefined && { limit: options.limit }), - }) + const totalLimit = options?.limit + const logStreams: DescribedLogStream[] = [] + let nextToken: string | undefined + + for (let page = 0; page < MAX_LOG_STREAMS_PAGES; page++) { + const pageLimit = + totalLimit !== undefined + ? Math.min(LOG_STREAMS_PAGE_SIZE, totalLimit - logStreams.length) + : LOG_STREAMS_PAGE_SIZE + + const command = new DescribeLogStreamsCommand({ + logGroupName, + ...(hasPrefix + ? { orderBy: 'LogStreamName', logStreamNamePrefix: options!.prefix } + : { orderBy: 'LastEventTime', descending: true }), + limit: pageLimit, + ...(nextToken && { nextToken }), + }) + + const response = await client.send(command) + + for (const ls of response.logStreams ?? []) { + logStreams.push({ + logStreamName: ls.logStreamName ?? '', + lastEventTimestamp: ls.lastEventTimestamp, + firstEventTimestamp: ls.firstEventTimestamp, + creationTime: ls.creationTime, + storedBytes: ls.storedBytes ?? 0, + }) + } + + nextToken = response.nextToken + if (!nextToken) break + if (totalLimit !== undefined && logStreams.length >= totalLimit) break + + if (page === MAX_LOG_STREAMS_PAGES - 1) { + logger.warn( + `DescribeLogStreams hit pagination cap of ${MAX_LOG_STREAMS_PAGES} pages; log stream list may be incomplete`, + { logGroupName } + ) + } + } - const response = await client.send(command) return { - logStreams: (response.logStreams ?? []).map((ls) => ({ - logStreamName: ls.logStreamName ?? '', - lastEventTimestamp: ls.lastEventTimestamp, - firstEventTimestamp: ls.firstEventTimestamp, - creationTime: ls.creationTime, - storedBytes: ls.storedBytes ?? 0, - })), + logStreams: totalLimit !== undefined ? logStreams.slice(0, totalLimit) : logStreams, } } diff --git a/apps/sim/app/api/tools/drive/files/route.ts b/apps/sim/app/api/tools/drive/files/route.ts index 773fd2ae97..4c38334b14 100644 --- a/apps/sim/app/api/tools/drive/files/route.ts +++ b/apps/sim/app/api/tools/drive/files/route.ts @@ -7,12 +7,21 @@ import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { validateAlphanumericId } from '@/lib/core/security/input-validation' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { drainGooglePagedList, GooglePageError } from '@/lib/oauth/google-pagination' import { getScopesForService } from '@/lib/oauth/utils' import { refreshAccessTokenIfNeeded, ServiceAccountTokenError } from '@/app/api/auth/oauth/utils' export const dynamic = 'force-dynamic' const logger = createLogger('GoogleDriveFilesAPI') +const MAX_DRIVE_FILE_PAGES = 20 +const DRIVE_FILE_PAGE_SIZE = 100 + +interface DriveFilesResponse { + files?: DriveFile[] + nextPageToken?: string +} + function escapeForDriveQuery(value: string): string { return value.replace(/\\/g, '\\\\').replace(/'/g, "\\'") } @@ -138,34 +147,56 @@ export const GET = withRouteHandler(async (request: NextRequest) => { if (query) { qParts.push(`name contains '${escapeForDriveQuery(query)}'`) } - const q = encodeURIComponent(qParts.join(' and ')) + const q = qParts.join(' and ') - const response = await fetch( - `https://www.googleapis.com/drive/v3/files?q=${q}&corpora=allDrives&supportsAllDrives=true&includeItemsFromAllDrives=true&fields=files(id,name,mimeType,iconLink,webViewLink,thumbnailLink,createdTime,modifiedTime,size,owners,parents)`, - { - headers: { - Authorization: `Bearer ${accessToken}`, + let files: DriveFile[] + try { + const drained = await drainGooglePagedList({ + buildUrl: (pageToken) => { + const url = new URL('https://www.googleapis.com/drive/v3/files') + url.searchParams.set('q', q) + url.searchParams.set('corpora', 'allDrives') + url.searchParams.set('supportsAllDrives', 'true') + url.searchParams.set('includeItemsFromAllDrives', 'true') + url.searchParams.set('pageSize', String(DRIVE_FILE_PAGE_SIZE)) + url.searchParams.set( + 'fields', + 'nextPageToken,files(id,name,mimeType,iconLink,webViewLink,thumbnailLink,createdTime,modifiedTime,size,owners,parents)' + ) + if (pageToken) url.searchParams.set('pageToken', pageToken) + return url.toString() }, - } - ) - - if (!response.ok) { - const error = await response.json().catch(() => ({ error: { message: 'Unknown error' } })) - logger.error(`[${requestId}] Google Drive API error`, { - status: response.status, - error: error.error?.message || 'Failed to fetch files from Google Drive', + fetch: (url) => + fetch(url, { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }), + parseError: (response) => + response.json().catch(() => ({ error: { message: 'Unknown error' } })), + getItems: (body) => body.files, + getNextPageToken: (body) => body.nextPageToken, + maxPages: MAX_DRIVE_FILE_PAGES, + label: 'Google Drive files', }) - return NextResponse.json( - { - error: error.error?.message || 'Failed to fetch files from Google Drive', - }, - { status: response.status } - ) + files = drained.items + } catch (error) { + if (error instanceof GooglePageError) { + const errorBody = error.body as { error?: { message?: string } } + logger.error(`[${requestId}] Google Drive API error`, { + status: error.status, + error: errorBody?.error?.message || 'Failed to fetch files from Google Drive', + }) + return NextResponse.json( + { + error: errorBody?.error?.message || 'Failed to fetch files from Google Drive', + }, + { status: error.status } + ) + } + throw error } - const data = await response.json() - let files: DriveFile[] = data.files || [] - if (mimeType === 'application/vnd.google-apps.spreadsheet') { files = files.filter( (file: DriveFile) => file.mimeType === 'application/vnd.google-apps.spreadsheet' diff --git a/apps/sim/app/api/tools/google_bigquery/datasets/route.ts b/apps/sim/app/api/tools/google_bigquery/datasets/route.ts index a4c97d6850..695a371e17 100644 --- a/apps/sim/app/api/tools/google_bigquery/datasets/route.ts +++ b/apps/sim/app/api/tools/google_bigquery/datasets/route.ts @@ -5,6 +5,7 @@ import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { drainGooglePagedList, GooglePageError } from '@/lib/oauth/google-pagination' import { getScopesForService } from '@/lib/oauth/utils' import { refreshAccessTokenIfNeeded, ServiceAccountTokenError } from '@/app/api/auth/oauth/utils' @@ -12,6 +13,19 @@ const logger = createLogger('GoogleBigQueryDatasetsAPI') export const dynamic = 'force-dynamic' +const MAX_DATASET_PAGES = 20 +const DATASET_PAGE_SIZE = 200 + +interface BigQueryDataset { + datasetReference: { datasetId: string; projectId: string } + friendlyName?: string +} + +interface BigQueryDatasetsResponse { + datasets?: BigQueryDataset[] + nextPageToken?: string +} + /** * POST /api/tools/google_bigquery/datasets * @@ -71,41 +85,46 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } - const response = await fetch( - `https://bigquery.googleapis.com/bigquery/v2/projects/${encodeURIComponent(projectId)}/datasets?maxResults=200`, - { - headers: { - Authorization: `Bearer ${accessToken}`, - 'Content-Type': 'application/json', - }, - } - ) + const { items } = await drainGooglePagedList({ + buildUrl: (pageToken) => { + const url = new URL( + `https://bigquery.googleapis.com/bigquery/v2/projects/${encodeURIComponent(projectId)}/datasets` + ) + url.searchParams.set('maxResults', String(DATASET_PAGE_SIZE)) + if (pageToken) url.searchParams.set('pageToken', pageToken) + return url.toString() + }, + fetch: (url) => + fetch(url, { + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + }), + parseError: (response) => response.json().catch(() => ({})), + getItems: (body) => body.datasets, + getNextPageToken: (body) => body.nextPageToken, + maxPages: MAX_DATASET_PAGES, + label: 'BigQuery datasets', + }) - if (!response.ok) { - const errorData = await response.json().catch(() => ({})) - logger.error('Failed to fetch BigQuery datasets', { - status: response.status, - error: errorData, - }) - return NextResponse.json( - { error: 'Failed to fetch BigQuery datasets', details: errorData }, - { status: response.status } - ) - } - - const data = await response.json() - const datasets = (data.datasets || []).map( - (ds: { - datasetReference: { datasetId: string; projectId: string } - friendlyName?: string - }) => ({ - datasetReference: ds.datasetReference, - friendlyName: ds.friendlyName, - }) - ) + const datasets = items.map((ds) => ({ + datasetReference: ds.datasetReference, + friendlyName: ds.friendlyName, + })) return NextResponse.json({ datasets }) } catch (error) { + if (error instanceof GooglePageError) { + logger.error('Failed to fetch BigQuery datasets', { + status: error.status, + error: error.body, + }) + return NextResponse.json( + { error: 'Failed to fetch BigQuery datasets', details: error.body }, + { status: error.status } + ) + } if (error instanceof ServiceAccountTokenError) { return NextResponse.json({ error: error.message }, { status: 400 }) } diff --git a/apps/sim/app/api/tools/google_bigquery/tables/route.ts b/apps/sim/app/api/tools/google_bigquery/tables/route.ts index 2f6320a0fe..af01379059 100644 --- a/apps/sim/app/api/tools/google_bigquery/tables/route.ts +++ b/apps/sim/app/api/tools/google_bigquery/tables/route.ts @@ -5,6 +5,7 @@ import { parseRequest } from '@/lib/api/server' import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { drainGooglePagedList, GooglePageError } from '@/lib/oauth/google-pagination' import { getScopesForService } from '@/lib/oauth/utils' import { refreshAccessTokenIfNeeded, ServiceAccountTokenError } from '@/app/api/auth/oauth/utils' @@ -12,6 +13,19 @@ const logger = createLogger('GoogleBigQueryTablesAPI') export const dynamic = 'force-dynamic' +const MAX_TABLE_PAGES = 20 +const TABLE_PAGE_SIZE = 200 + +interface BigQueryTable { + tableReference: { tableId: string } + friendlyName?: string +} + +interface BigQueryTablesResponse { + tables?: BigQueryTable[] + nextPageToken?: string +} + export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() try { @@ -68,38 +82,46 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } - const response = await fetch( - `https://bigquery.googleapis.com/bigquery/v2/projects/${encodeURIComponent(projectId)}/datasets/${encodeURIComponent(datasetId)}/tables?maxResults=200`, - { - headers: { - Authorization: `Bearer ${accessToken}`, - 'Content-Type': 'application/json', - }, - } - ) + const { items } = await drainGooglePagedList({ + buildUrl: (pageToken) => { + const url = new URL( + `https://bigquery.googleapis.com/bigquery/v2/projects/${encodeURIComponent(projectId)}/datasets/${encodeURIComponent(datasetId)}/tables` + ) + url.searchParams.set('maxResults', String(TABLE_PAGE_SIZE)) + if (pageToken) url.searchParams.set('pageToken', pageToken) + return url.toString() + }, + fetch: (url) => + fetch(url, { + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + }), + parseError: (response) => response.json().catch(() => ({})), + getItems: (body) => body.tables, + getNextPageToken: (body) => body.nextPageToken, + maxPages: MAX_TABLE_PAGES, + label: 'BigQuery tables', + }) - if (!response.ok) { - const errorData = await response.json().catch(() => ({})) - logger.error('Failed to fetch BigQuery tables', { - status: response.status, - error: errorData, - }) - return NextResponse.json( - { error: 'Failed to fetch BigQuery tables', details: errorData }, - { status: response.status } - ) - } - - const data = await response.json() - const tables = (data.tables || []).map( - (t: { tableReference: { tableId: string }; friendlyName?: string }) => ({ - tableReference: t.tableReference, - friendlyName: t.friendlyName, - }) - ) + const tables = items.map((t) => ({ + tableReference: t.tableReference, + friendlyName: t.friendlyName, + })) return NextResponse.json({ tables }) } catch (error) { + if (error instanceof GooglePageError) { + logger.error('Failed to fetch BigQuery tables', { + status: error.status, + error: error.body, + }) + return NextResponse.json( + { error: 'Failed to fetch BigQuery tables', details: error.body }, + { status: error.status } + ) + } if (error instanceof ServiceAccountTokenError) { return NextResponse.json({ error: error.message }, { status: 400 }) } diff --git a/apps/sim/app/api/tools/google_calendar/calendars/route.ts b/apps/sim/app/api/tools/google_calendar/calendars/route.ts index e1ac55b13e..752cc72b22 100644 --- a/apps/sim/app/api/tools/google_calendar/calendars/route.ts +++ b/apps/sim/app/api/tools/google_calendar/calendars/route.ts @@ -5,12 +5,16 @@ import { parseRequest } from '@/lib/api/server' import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { drainGooglePagedList, GooglePageError } from '@/lib/oauth/google-pagination' import { getScopesForService } from '@/lib/oauth/utils' import { refreshAccessTokenIfNeeded, ServiceAccountTokenError } from '@/app/api/auth/oauth/utils' export const dynamic = 'force-dynamic' const logger = createLogger('GoogleCalendarAPI') +const MAX_CALENDAR_PAGES = 20 +const CALENDAR_PAGE_SIZE = 250 + interface CalendarListItem { id: string summary: string @@ -21,6 +25,11 @@ interface CalendarListItem { foregroundColor?: string } +interface CalendarListResponse { + items?: CalendarListItem[] + nextPageToken?: string +} + /** * Get calendars from Google Calendar */ @@ -64,35 +73,50 @@ export const GET = withRouteHandler(async (request: NextRequest) => { } logger.info(`[${requestId}] Fetching calendars from Google Calendar API`) - const calendarResponse = await fetch( - 'https://www.googleapis.com/calendar/v3/users/me/calendarList', - { - method: 'GET', - headers: { - Authorization: `Bearer ${accessToken}`, - 'Content-Type': 'application/json', + + let calendars: CalendarListItem[] + try { + const drained = await drainGooglePagedList({ + buildUrl: (pageToken) => { + const url = new URL('https://www.googleapis.com/calendar/v3/users/me/calendarList') + url.searchParams.set('maxResults', String(CALENDAR_PAGE_SIZE)) + if (pageToken) url.searchParams.set('pageToken', pageToken) + return url.toString() }, - } - ) - - if (!calendarResponse.ok) { - const errorData = await calendarResponse - .text() - .then((text) => JSON.parse(text)) - .catch(() => ({ error: { message: 'Unknown error' } })) - logger.error(`[${requestId}] Google Calendar API error`, { - status: calendarResponse.status, - error: errorData.error?.message || 'Failed to fetch calendars', + fetch: (url) => + fetch(url, { + method: 'GET', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + }), + parseError: (response) => + response + .text() + .then((text) => JSON.parse(text)) + .catch(() => ({ error: { message: 'Unknown error' } })), + getItems: (body) => body.items, + getNextPageToken: (body) => body.nextPageToken, + maxPages: MAX_CALENDAR_PAGES, + label: 'Google Calendar calendars', }) - return NextResponse.json( - { error: errorData.error?.message || 'Failed to fetch calendars' }, - { status: calendarResponse.status } - ) + calendars = drained.items + } catch (error) { + if (error instanceof GooglePageError) { + const errorData = error.body as { error?: { message?: string } } + logger.error(`[${requestId}] Google Calendar API error`, { + status: error.status, + error: errorData?.error?.message || 'Failed to fetch calendars', + }) + return NextResponse.json( + { error: errorData?.error?.message || 'Failed to fetch calendars' }, + { status: error.status } + ) + } + throw error } - const data = await calendarResponse.json() - const calendars: CalendarListItem[] = data.items || [] - calendars.sort((a, b) => { if (a.primary && !b.primary) return -1 if (!a.primary && b.primary) return 1 diff --git a/apps/sim/app/api/tools/google_tasks/task-lists/route.ts b/apps/sim/app/api/tools/google_tasks/task-lists/route.ts index c5ee97bb23..80c5a99f59 100644 --- a/apps/sim/app/api/tools/google_tasks/task-lists/route.ts +++ b/apps/sim/app/api/tools/google_tasks/task-lists/route.ts @@ -5,6 +5,7 @@ import { parseRequest } from '@/lib/api/server' import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { drainGooglePagedList, GooglePageError } from '@/lib/oauth/google-pagination' import { getScopesForService } from '@/lib/oauth/utils' import { refreshAccessTokenIfNeeded, ServiceAccountTokenError } from '@/app/api/auth/oauth/utils' @@ -12,6 +13,19 @@ const logger = createLogger('GoogleTasksTaskListsAPI') export const dynamic = 'force-dynamic' +const MAX_TASK_LIST_PAGES = 20 +const TASK_LIST_PAGE_SIZE = 1000 + +interface GoogleTaskList { + id: string + title: string +} + +interface GoogleTaskListsResponse { + items?: GoogleTaskList[] + nextPageToken?: string +} + export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() try { @@ -56,33 +70,44 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } - const response = await fetch('https://tasks.googleapis.com/tasks/v1/users/@me/lists', { - headers: { - Authorization: `Bearer ${accessToken}`, - 'Content-Type': 'application/json', + const { items } = await drainGooglePagedList({ + buildUrl: (pageToken) => { + const url = new URL('https://tasks.googleapis.com/tasks/v1/users/@me/lists') + url.searchParams.set('maxResults', String(TASK_LIST_PAGE_SIZE)) + if (pageToken) url.searchParams.set('pageToken', pageToken) + return url.toString() }, + fetch: (url) => + fetch(url, { + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + }), + parseError: (response) => response.json().catch(() => ({})), + getItems: (body) => body.items, + getNextPageToken: (body) => body.nextPageToken, + maxPages: MAX_TASK_LIST_PAGES, + label: 'Google Tasks task lists', }) - if (!response.ok) { - const errorData = await response.json().catch(() => ({})) - logger.error('Failed to fetch Google Tasks task lists', { - status: response.status, - error: errorData, - }) - return NextResponse.json( - { error: 'Failed to fetch Google Tasks task lists', details: errorData }, - { status: response.status } - ) - } - - const data = await response.json() - const taskLists = (data.items || []).map((list: { id: string; title: string }) => ({ + const taskLists = items.map((list) => ({ id: list.id, title: list.title, })) return NextResponse.json({ taskLists }) } catch (error) { + if (error instanceof GooglePageError) { + logger.error('Failed to fetch Google Tasks task lists', { + status: error.status, + error: error.body, + }) + return NextResponse.json( + { error: 'Failed to fetch Google Tasks task lists', details: error.body }, + { status: error.status } + ) + } if (error instanceof ServiceAccountTokenError) { return NextResponse.json({ error: error.message }, { status: 400 }) } diff --git a/apps/sim/app/api/tools/jira/projects/route.ts b/apps/sim/app/api/tools/jira/projects/route.ts index 2c99f2e447..2ee1244cb2 100644 --- a/apps/sim/app/api/tools/jira/projects/route.ts +++ b/apps/sim/app/api/tools/jira/projects/route.ts @@ -14,6 +14,77 @@ export const dynamic = 'force-dynamic' const logger = createLogger('JiraProjectsAPI') +const JIRA_PROJECTS_PAGE_SIZE = 50 +const MAX_JIRA_PROJECTS_PAGES = 40 + +interface JiraProjectSearchPage { + values?: unknown[] + isLast?: boolean + maxResults?: number +} + +/** + * Drains the offset-paginated Jira `/project/search` endpoint, advancing + * `startAt` by the server-returned page size until `isLast === true` (or a short + * page is seen). Bounded by `MAX_JIRA_PROJECTS_PAGES`; emits a `logger.warn` and + * returns the partial set rather than looping unbounded when the cap is hit. + */ +async function fetchAllJiraProjects( + apiUrl: string, + baseParams: URLSearchParams, + accessToken: string +): Promise<{ values: unknown[]; lastResponse: Response }> { + const values: unknown[] = [] + let startAt = 0 + let lastResponse: Response + + for (let page = 0; page < MAX_JIRA_PROJECTS_PAGES; page++) { + const params = new URLSearchParams(baseParams) + params.set('startAt', String(startAt)) + params.set('maxResults', String(JIRA_PROJECTS_PAGE_SIZE)) + + const finalUrl = `${apiUrl}?${params.toString()}` + logger.info(`Fetching Jira projects from: ${finalUrl}`) + + const response = await fetch(finalUrl, { + method: 'GET', + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/json', + }, + }) + + logger.info(`Response status: ${response.status} ${response.statusText}`) + + if (!response.ok) { + return { values, lastResponse: response } + } + + const data = (await response.json()) as JiraProjectSearchPage + lastResponse = response + + const pageValues = data.values ?? [] + values.push(...pageValues) + + const pageSize = + data.maxResults && data.maxResults > 0 ? data.maxResults : JIRA_PROJECTS_PAGE_SIZE + if (data.isLast === true || pageValues.length < pageSize) { + return { values, lastResponse } + } + + startAt += pageValues.length + + if (page === MAX_JIRA_PROJECTS_PAGES - 1) { + logger.warn('Jira project search hit pagination cap; project list may be incomplete', { + pages: MAX_JIRA_PROJECTS_PAGES, + collected: values.length, + }) + } + } + + return { values, lastResponse: lastResponse! } +} + export const GET = withRouteHandler(async (request: NextRequest) => { try { const auth = await checkSessionOrInternalAuth(request) @@ -51,35 +122,28 @@ export const GET = withRouteHandler(async (request: NextRequest) => { queryParams.append('orderBy', 'name') queryParams.append('expand', 'description,lead,url,projectKeys') - const finalUrl = `${apiUrl}?${queryParams.toString()}` - logger.info(`Fetching Jira projects from: ${finalUrl}`) + const { values, lastResponse } = await fetchAllJiraProjects(apiUrl, queryParams, accessToken) - const response = await fetch(finalUrl, { - method: 'GET', - headers: { - Authorization: `Bearer ${accessToken}`, - Accept: 'application/json', - }, - }) - - logger.info(`Response status: ${response.status} ${response.statusText}`) - - if (!response.ok) { - const errorText = await response.text() - logger.error('Jira API error:', { status: response.status, error: errorText }) + if (!lastResponse.ok) { + const errorText = await lastResponse.text() + logger.error('Jira API error:', { status: lastResponse.status, error: errorText }) return NextResponse.json( - { error: parseAtlassianErrorMessage(response.status, response.statusText, errorText) }, - { status: response.status } + { + error: parseAtlassianErrorMessage( + lastResponse.status, + lastResponse.statusText, + errorText + ), + }, + { status: lastResponse.status } ) } - const data = await response.json() - - logger.info(`Jira API Response Status: ${response.status}`) - logger.info(`Found projects: ${data.values?.length || 0}`) + logger.info(`Jira API Response Status: ${lastResponse.status}`) + logger.info(`Found projects: ${values.length}`) const projects = - data.values?.map((project: any) => ({ + values.map((project: any) => ({ id: project.id, key: project.key, name: project.name, diff --git a/apps/sim/app/api/tools/jsm/selector-requesttypes/route.ts b/apps/sim/app/api/tools/jsm/selector-requesttypes/route.ts index 01c1682e1e..b23b4b7c7a 100644 --- a/apps/sim/app/api/tools/jsm/selector-requesttypes/route.ts +++ b/apps/sim/app/api/tools/jsm/selector-requesttypes/route.ts @@ -14,6 +14,72 @@ const logger = createLogger('JsmSelectorRequestTypesAPI') export const dynamic = 'force-dynamic' +const JSM_REQUEST_TYPES_PAGE_SIZE = 100 +const MAX_JSM_REQUEST_TYPES_PAGES = 50 + +interface JsmPagedResponse { + values?: T[] + isLastPage?: boolean + _links?: { next?: string } +} + +interface JsmRequestTypeValue { + id: string + name: string +} + +/** + * Drains the offset-paginated JSM `/servicedesk/{id}/requesttype` endpoint, + * advancing `start` by the number of rows actually returned until + * `isLastPage === true` (or `_links.next` is absent, or a page comes back + * empty). Advancing by the real row count — not the requested `limit` — + * prevents skipping items if the server returns a short non-final page. Bounded + * by `MAX_JSM_REQUEST_TYPES_PAGES`; emits a `logger.warn` and returns the + * partial set rather than looping unbounded when the cap is hit. + */ +async function fetchAllJsmRequestTypes( + requestTypeUrl: string, + accessToken: string +): Promise<{ values: JsmRequestTypeValue[]; lastResponse: Response }> { + const values: JsmRequestTypeValue[] = [] + let start = 0 + let lastResponse: Response + + for (let page = 0; page < MAX_JSM_REQUEST_TYPES_PAGES; page++) { + const url = `${requestTypeUrl}?start=${start}&limit=${JSM_REQUEST_TYPES_PAGE_SIZE}` + + const response = await fetch(url, { + method: 'GET', + headers: getJsmHeaders(accessToken), + }) + + if (!response.ok) { + return { values, lastResponse: response } + } + + const data = (await response.json()) as JsmPagedResponse + lastResponse = response + + const pageValues = data.values ?? [] + values.push(...pageValues) + + if (data.isLastPage === true || !data._links?.next || pageValues.length === 0) { + return { values, lastResponse } + } + + start += pageValues.length + + if (page === MAX_JSM_REQUEST_TYPES_PAGES - 1) { + logger.warn('JSM request type list hit pagination cap; list may be incomplete', { + pages: MAX_JSM_REQUEST_TYPES_PAGES, + collected: values.length, + }) + } + } + + return { values, lastResponse: lastResponse! } +} + export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() try { @@ -72,28 +138,30 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } const baseUrl = getJsmApiBaseUrl(cloudIdValidation.sanitized!) - const url = `${baseUrl}/servicedesk/${serviceDeskIdValidation.sanitized}/requesttype?limit=100` + const requestTypeUrl = `${baseUrl}/servicedesk/${serviceDeskIdValidation.sanitized}/requesttype` - const response = await fetch(url, { - method: 'GET', - headers: getJsmHeaders(accessToken), - }) + const { values, lastResponse } = await fetchAllJsmRequestTypes(requestTypeUrl, accessToken) - if (!response.ok) { - const errorText = await response.text() + if (!lastResponse.ok) { + const errorText = await lastResponse.text() logger.error('JSM API error:', { - status: response.status, - statusText: response.statusText, + status: lastResponse.status, + statusText: lastResponse.statusText, error: errorText, }) return NextResponse.json( - { error: parseAtlassianErrorMessage(response.status, response.statusText, errorText) }, - { status: response.status } + { + error: parseAtlassianErrorMessage( + lastResponse.status, + lastResponse.statusText, + errorText + ), + }, + { status: lastResponse.status } ) } - const data = await response.json() - const requestTypes = (data.values || []).map((rt: { id: string; name: string }) => ({ + const requestTypes = values.map((rt) => ({ id: rt.id, name: rt.name, })) diff --git a/apps/sim/app/api/tools/jsm/selector-servicedesks/route.ts b/apps/sim/app/api/tools/jsm/selector-servicedesks/route.ts index c1efdb0f93..786483630d 100644 --- a/apps/sim/app/api/tools/jsm/selector-servicedesks/route.ts +++ b/apps/sim/app/api/tools/jsm/selector-servicedesks/route.ts @@ -14,6 +14,72 @@ const logger = createLogger('JsmSelectorServiceDesksAPI') export const dynamic = 'force-dynamic' +const JSM_SERVICE_DESKS_PAGE_SIZE = 100 +const MAX_JSM_SERVICE_DESKS_PAGES = 50 + +interface JsmPagedResponse { + values?: T[] + isLastPage?: boolean + _links?: { next?: string } +} + +interface JsmServiceDeskValue { + id: string + projectName: string +} + +/** + * Drains the offset-paginated JSM `/servicedesk` endpoint, advancing `start` by + * the number of rows actually returned until `isLastPage === true` (or + * `_links.next` is absent, or a page comes back empty). Advancing by the real + * row count — not the requested `limit` — prevents skipping items if the server + * returns a short non-final page. Bounded by `MAX_JSM_SERVICE_DESKS_PAGES`; + * emits a `logger.warn` and returns the partial set rather than looping + * unbounded when the cap is hit. + */ +async function fetchAllJsmServiceDesks( + baseUrl: string, + accessToken: string +): Promise<{ values: JsmServiceDeskValue[]; lastResponse: Response }> { + const values: JsmServiceDeskValue[] = [] + let start = 0 + let lastResponse: Response + + for (let page = 0; page < MAX_JSM_SERVICE_DESKS_PAGES; page++) { + const url = `${baseUrl}/servicedesk?start=${start}&limit=${JSM_SERVICE_DESKS_PAGE_SIZE}` + + const response = await fetch(url, { + method: 'GET', + headers: getJsmHeaders(accessToken), + }) + + if (!response.ok) { + return { values, lastResponse: response } + } + + const data = (await response.json()) as JsmPagedResponse + lastResponse = response + + const pageValues = data.values ?? [] + values.push(...pageValues) + + if (data.isLastPage === true || !data._links?.next || pageValues.length === 0) { + return { values, lastResponse } + } + + start += pageValues.length + + if (page === MAX_JSM_SERVICE_DESKS_PAGES - 1) { + logger.warn('JSM service desk list hit pagination cap; list may be incomplete', { + pages: MAX_JSM_SERVICE_DESKS_PAGES, + collected: values.length, + }) + } + } + + return { values, lastResponse: lastResponse! } +} + export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() try { @@ -63,28 +129,29 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } const baseUrl = getJsmApiBaseUrl(cloudIdValidation.sanitized!) - const url = `${baseUrl}/servicedesk?limit=100` - const response = await fetch(url, { - method: 'GET', - headers: getJsmHeaders(accessToken), - }) + const { values, lastResponse } = await fetchAllJsmServiceDesks(baseUrl, accessToken) - if (!response.ok) { - const errorText = await response.text() + if (!lastResponse.ok) { + const errorText = await lastResponse.text() logger.error('JSM API error:', { - status: response.status, - statusText: response.statusText, + status: lastResponse.status, + statusText: lastResponse.statusText, error: errorText, }) return NextResponse.json( - { error: parseAtlassianErrorMessage(response.status, response.statusText, errorText) }, - { status: response.status } + { + error: parseAtlassianErrorMessage( + lastResponse.status, + lastResponse.statusText, + errorText + ), + }, + { status: lastResponse.status } ) } - const data = await response.json() - const serviceDesks = (data.values || []).map((sd: { id: string; projectName: string }) => ({ + const serviceDesks = values.map((sd) => ({ id: sd.id, name: sd.projectName, })) diff --git a/apps/sim/app/api/tools/linear/projects/route.ts b/apps/sim/app/api/tools/linear/projects/route.ts index e7ca9bab1a..9b0b500e0d 100644 --- a/apps/sim/app/api/tools/linear/projects/route.ts +++ b/apps/sim/app/api/tools/linear/projects/route.ts @@ -1,4 +1,4 @@ -import type { Project } from '@linear/sdk' +import type { Project, Team } from '@linear/sdk' import { LinearClient } from '@linear/sdk' import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' @@ -13,6 +13,50 @@ export const dynamic = 'force-dynamic' const logger = createLogger('LinearProjectsAPI') +/** Linear's maximum page size for a single connection request. */ +const LINEAR_PAGE_SIZE = 250 + +/** + * Upper bound on pages to drain from a single team's projects connection. At + * 250 projects/page this covers 2,500 projects per team; the cap guards + * against runaway loops on a broken `hasNextPage` rather than a realistic + * limit. + */ +const MAX_PROJECTS_PAGES = 10 + +/** + * Drains a single team's projects connection by following + * `pageInfo.endCursor` until `hasNextPage` is false. Bounded by + * `MAX_PROJECTS_PAGES`; logs a warning if the cap is hit so a truncated list + * is visible rather than silently dropped. + */ +async function fetchAllTeamProjects(team: Team): Promise { + const projects: Project[] = [] + let after: string | undefined + + for (let page = 0; page < MAX_PROJECTS_PAGES; page++) { + const result = await team.projects({ first: LINEAR_PAGE_SIZE, after }) + projects.push(...result.nodes) + + if (!result.pageInfo.hasNextPage) { + return projects + } + after = result.pageInfo.endCursor ?? undefined + if (!after) { + return projects + } + if (page === MAX_PROJECTS_PAGES - 1) { + logger.warn('Linear projects pagination hit cap; project list may be incomplete', { + teamId: team.id, + cap: MAX_PROJECTS_PAGES, + fetched: projects.length, + }) + } + } + + return projects +} + export const POST = withRouteHandler(async (request: NextRequest) => { try { const parsed = await parseRequest(linearProjectsSelectorContract, request, {}) @@ -59,8 +103,8 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const perTeam = await Promise.all( teamIds.map(async (id) => { const team = await linearClient.team(id) - const result = await team.projects() - return result.nodes.map((project: Project) => ({ + const teamProjects = await fetchAllTeamProjects(team) + return teamProjects.map((project: Project) => ({ id: project.id, name: project.name, })) diff --git a/apps/sim/app/api/tools/linear/teams/route.ts b/apps/sim/app/api/tools/linear/teams/route.ts index 89b02a6e24..f71adec6b0 100644 --- a/apps/sim/app/api/tools/linear/teams/route.ts +++ b/apps/sim/app/api/tools/linear/teams/route.ts @@ -13,6 +13,48 @@ export const dynamic = 'force-dynamic' const logger = createLogger('LinearTeamsAPI') +/** Linear's maximum page size for a single connection request. */ +const LINEAR_PAGE_SIZE = 250 + +/** + * Upper bound on pages to drain from the teams connection. At 250 teams/page + * this covers 2,500 teams; the cap guards against runaway loops on a broken + * `hasNextPage` rather than a realistic limit. + */ +const MAX_TEAMS_PAGES = 10 + +/** + * Drains the full Linear teams connection by following + * `pageInfo.endCursor` until `hasNextPage` is false. Bounded by + * `MAX_TEAMS_PAGES`; logs a warning if the cap is hit so a truncated list is + * visible rather than silently dropped. + */ +async function fetchAllTeams(linearClient: LinearClient): Promise { + const teams: Team[] = [] + let after: string | undefined + + for (let page = 0; page < MAX_TEAMS_PAGES; page++) { + const result = await linearClient.teams({ first: LINEAR_PAGE_SIZE, after }) + teams.push(...result.nodes) + + if (!result.pageInfo.hasNextPage) { + return teams + } + after = result.pageInfo.endCursor ?? undefined + if (!after) { + return teams + } + if (page === MAX_TEAMS_PAGES - 1) { + logger.warn('Linear teams pagination hit cap; team list may be incomplete', { + cap: MAX_TEAMS_PAGES, + fetched: teams.length, + }) + } + } + + return teams +} + export const POST = withRouteHandler(async (request: NextRequest) => { try { const requestId = generateRequestId() @@ -45,8 +87,8 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } const linearClient = new LinearClient({ accessToken }) - const teamsResult = await linearClient.teams() - const teams = teamsResult.nodes.map((team: Team) => ({ + const allTeams = await fetchAllTeams(linearClient) + const teams = allTeams.map((team: Team) => ({ id: team.id, name: team.name, })) diff --git a/apps/sim/app/api/tools/microsoft-teams/channels/route.ts b/apps/sim/app/api/tools/microsoft-teams/channels/route.ts index 6570d5fdd4..c8bd6ddcb5 100644 --- a/apps/sim/app/api/tools/microsoft-teams/channels/route.ts +++ b/apps/sim/app/api/tools/microsoft-teams/channels/route.ts @@ -7,11 +7,25 @@ import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { validateMicrosoftGraphId } from '@/lib/core/security/input-validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' +import { assertGraphNextPageUrl, getGraphNextPageUrl } from '@/tools/sharepoint/utils' export const dynamic = 'force-dynamic' const logger = createLogger('TeamsChannelsAPI') +/** + * Upper bound on Microsoft Graph pages drained when listing a team's channels. + * The `teams/{id}/channels` endpoint does not support `$top`, so paging is + * driven entirely by the server via `@odata.nextLink`. The cap prevents an + * unbounded loop; hitting it is logged as a warning. + */ +const MAX_CHANNELS_PAGES = 20 + +interface GraphChannel { + id: string + displayName?: string +} + export const POST = withRouteHandler(async (request: NextRequest) => { try { const parsed = await parseRequest(microsoftChannelsSelectorContract, request, {}) @@ -52,40 +66,59 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } - const response = await fetch( - `https://graph.microsoft.com/v1.0/teams/${encodeURIComponent(teamId)}/channels`, - { + const channels: GraphChannel[] = [] + let nextPageUrl: string | undefined = + `https://graph.microsoft.com/v1.0/teams/${encodeURIComponent(teamId)}/channels` + + for (let page = 0; page < MAX_CHANNELS_PAGES; page++) { + const response = await fetch(nextPageUrl, { method: 'GET', headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json', }, - } - ) - - if (!response.ok) { - const errorData = await response.json() - logger.error('Microsoft Graph API error getting channels', { - status: response.status, - error: errorData, - endpoint: `https://graph.microsoft.com/v1.0/teams/${teamId}/channels`, }) - if (response.status === 401) { - return NextResponse.json( - { - error: 'Authentication failed. Please reconnect your Microsoft Teams account.', - authRequired: true, - }, - { status: 401 } - ) + if (!response.ok) { + const errorData = await response.json() + logger.error('Microsoft Graph API error getting channels', { + status: response.status, + error: errorData, + endpoint: nextPageUrl, + }) + + if (response.status === 401) { + return NextResponse.json( + { + error: 'Authentication failed. Please reconnect your Microsoft Teams account.', + authRequired: true, + }, + { status: 401 } + ) + } + + throw new Error(`Microsoft Graph API error: ${JSON.stringify(errorData)}`) } - throw new Error(`Microsoft Graph API error: ${JSON.stringify(errorData)}`) - } + const data = await response.json() + if (Array.isArray(data.value)) { + channels.push(...(data.value as GraphChannel[])) + } - const data = await response.json() - const channels = data.value + const rawNextLink = getGraphNextPageUrl(data) + if (!rawNextLink) { + nextPageUrl = undefined + break + } + nextPageUrl = assertGraphNextPageUrl(rawNextLink) + + if (page === MAX_CHANNELS_PAGES - 1) { + logger.warn( + 'Hit Microsoft Graph channels pagination cap; channel list may be incomplete', + { maxPages: MAX_CHANNELS_PAGES, collected: channels.length } + ) + } + } return NextResponse.json({ channels: channels, diff --git a/apps/sim/app/api/tools/microsoft-teams/chats/route.ts b/apps/sim/app/api/tools/microsoft-teams/chats/route.ts index 832c720014..d709bcd62e 100644 --- a/apps/sim/app/api/tools/microsoft-teams/chats/route.ts +++ b/apps/sim/app/api/tools/microsoft-teams/chats/route.ts @@ -7,11 +7,29 @@ import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { validateMicrosoftGraphId } from '@/lib/core/security/input-validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' +import { assertGraphNextPageUrl, getGraphNextPageUrl } from '@/tools/sharepoint/utils' export const dynamic = 'force-dynamic' const logger = createLogger('TeamsChatsAPI') +/** + * Largest page size the `me/chats` Microsoft Graph endpoint permits via `$top`. + */ +const CHATS_PAGE_SIZE = 50 + +/** + * Upper bound on Microsoft Graph pages drained when listing the user's chats. + * Paging follows `@odata.nextLink`. The cap prevents an unbounded loop; hitting + * it is logged as a warning. + */ +const MAX_CHATS_PAGES = 20 + +interface GraphChat { + id: string + topic?: string +} + /** * Helper function to get chat members and create a meaningful name * @@ -153,39 +171,62 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: 'Could not retrieve access token' }, { status: 401 }) } - const response = await fetch('https://graph.microsoft.com/v1.0/me/chats', { - method: 'GET', - headers: { - Authorization: `Bearer ${accessToken}`, - 'Content-Type': 'application/json', - }, - }) + const rawChats: GraphChat[] = [] + let nextPageUrl: string | undefined = + `https://graph.microsoft.com/v1.0/me/chats?$top=${CHATS_PAGE_SIZE}` - if (!response.ok) { - const errorData = await response.json() - logger.error('Microsoft Graph API error getting chats', { - status: response.status, - error: errorData, - endpoint: 'https://graph.microsoft.com/v1.0/me/chats', + for (let page = 0; page < MAX_CHATS_PAGES; page++) { + const response = await fetch(nextPageUrl, { + method: 'GET', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, }) - if (response.status === 401) { - return NextResponse.json( - { - error: 'Authentication failed. Please reconnect your Microsoft Teams account.', - authRequired: true, - }, - { status: 401 } - ) + if (!response.ok) { + const errorData = await response.json() + logger.error('Microsoft Graph API error getting chats', { + status: response.status, + error: errorData, + endpoint: nextPageUrl, + }) + + if (response.status === 401) { + return NextResponse.json( + { + error: 'Authentication failed. Please reconnect your Microsoft Teams account.', + authRequired: true, + }, + { status: 401 } + ) + } + + throw new Error(`Microsoft Graph API error: ${JSON.stringify(errorData)}`) } - throw new Error(`Microsoft Graph API error: ${JSON.stringify(errorData)}`) + const data = await response.json() + if (Array.isArray(data.value)) { + rawChats.push(...(data.value as GraphChat[])) + } + + const rawNextLink = getGraphNextPageUrl(data) + if (!rawNextLink) { + nextPageUrl = undefined + break + } + nextPageUrl = assertGraphNextPageUrl(rawNextLink) + + if (page === MAX_CHATS_PAGES - 1) { + logger.warn('Hit Microsoft Graph chats pagination cap; chat list may be incomplete', { + maxPages: MAX_CHATS_PAGES, + collected: rawChats.length, + }) + } } - const data = await response.json() - const chats = await Promise.all( - data.value.map(async (chat: any) => ({ + rawChats.map(async (chat) => ({ id: chat.id, displayName: await getChatDisplayName(chat.id, accessToken, chat.topic), })) diff --git a/apps/sim/app/api/tools/microsoft-teams/teams/route.ts b/apps/sim/app/api/tools/microsoft-teams/teams/route.ts index 44c1d99706..990bfd282d 100644 --- a/apps/sim/app/api/tools/microsoft-teams/teams/route.ts +++ b/apps/sim/app/api/tools/microsoft-teams/teams/route.ts @@ -6,11 +6,25 @@ import { parseRequest } from '@/lib/api/server' import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' +import { assertGraphNextPageUrl, getGraphNextPageUrl } from '@/tools/sharepoint/utils' export const dynamic = 'force-dynamic' const logger = createLogger('TeamsTeamsAPI') +/** + * Upper bound on Microsoft Graph pages drained when listing the user's joined + * teams. The `me/joinedTeams` endpoint does not support `$top`, so paging is + * driven entirely by the server via `@odata.nextLink`. The cap prevents an + * unbounded loop; hitting it is logged as a warning. + */ +const MAX_TEAMS_PAGES = 20 + +interface GraphTeam { + id: string + displayName?: string +} + export const POST = withRouteHandler(async (request: NextRequest) => { try { const parsed = await parseRequest(microsoftTeamsSelectorContract, request, {}) @@ -46,38 +60,59 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } - const response = await fetch('https://graph.microsoft.com/v1.0/me/joinedTeams', { - method: 'GET', - headers: { - Authorization: `Bearer ${accessToken}`, - 'Content-Type': 'application/json', - }, - }) + const teams: GraphTeam[] = [] + let nextPageUrl: string | undefined = 'https://graph.microsoft.com/v1.0/me/joinedTeams' - if (!response.ok) { - const errorData = await response.json() - logger.error('Microsoft Graph API error getting teams', { - status: response.status, - error: errorData, - endpoint: 'https://graph.microsoft.com/v1.0/me/joinedTeams', + for (let page = 0; page < MAX_TEAMS_PAGES; page++) { + const response = await fetch(nextPageUrl, { + method: 'GET', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, }) - // Check for auth errors specifically - if (response.status === 401) { - return NextResponse.json( - { - error: 'Authentication failed. Please reconnect your Microsoft Teams account.', - authRequired: true, - }, - { status: 401 } - ) + if (!response.ok) { + const errorData = await response.json() + logger.error('Microsoft Graph API error getting teams', { + status: response.status, + error: errorData, + endpoint: nextPageUrl, + }) + + // Check for auth errors specifically + if (response.status === 401) { + return NextResponse.json( + { + error: 'Authentication failed. Please reconnect your Microsoft Teams account.', + authRequired: true, + }, + { status: 401 } + ) + } + + throw new Error(`Microsoft Graph API error: ${JSON.stringify(errorData)}`) } - throw new Error(`Microsoft Graph API error: ${JSON.stringify(errorData)}`) - } + const data = await response.json() + if (Array.isArray(data.value)) { + teams.push(...(data.value as GraphTeam[])) + } - const data = await response.json() - const teams = data.value + const rawNextLink = getGraphNextPageUrl(data) + if (!rawNextLink) { + nextPageUrl = undefined + break + } + nextPageUrl = assertGraphNextPageUrl(rawNextLink) + + if (page === MAX_TEAMS_PAGES - 1) { + logger.warn('Hit Microsoft Graph teams pagination cap; team list may be incomplete', { + maxPages: MAX_TEAMS_PAGES, + collected: teams.length, + }) + } + } return NextResponse.json({ teams: teams, diff --git a/apps/sim/app/api/tools/microsoft_excel/drives/route.ts b/apps/sim/app/api/tools/microsoft_excel/drives/route.ts index 2e0d1d80e4..97d921a5ea 100644 --- a/apps/sim/app/api/tools/microsoft_excel/drives/route.ts +++ b/apps/sim/app/api/tools/microsoft_excel/drives/route.ts @@ -8,11 +8,19 @@ import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' import { extractGraphError, GRAPH_ID_PATTERN } from '@/tools/microsoft_excel/utils' +import { assertGraphNextPageUrl, getGraphNextPageUrl } from '@/tools/sharepoint/utils' export const dynamic = 'force-dynamic' const logger = createLogger('MicrosoftExcelDrivesAPI') +/** + * Upper bound on Microsoft Graph pages drained when listing site drives. + * Each page returns up to `$top=999` drives, so this caps the result set at + * roughly 10k drives while preventing an unbounded server-side loop. + */ +const MAX_DRIVES_PAGES = 10 + interface GraphDrive { id: string name: string @@ -88,25 +96,41 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } // List all drives for the site - const url = `https://graph.microsoft.com/v1.0/sites/${siteId}/drives?$select=id,name,driveType,webUrl` + let nextUrl: string | undefined = + `https://graph.microsoft.com/v1.0/sites/${siteId}/drives?$select=id,name,driveType,webUrl&$top=999` - const response = await fetch(url, { - headers: { - Authorization: `Bearer ${accessToken}`, - }, - }) - - if (!response.ok) { - const errorMessage = await extractGraphError(response) - logger.error(`[${requestId}] Microsoft Graph API error fetching drives`, { - status: response.status, - error: errorMessage, + const rawDrives: GraphDrive[] = [] + for (let page = 0; page < MAX_DRIVES_PAGES && nextUrl; page++) { + const response = await fetch(nextUrl, { + headers: { + Authorization: `Bearer ${accessToken}`, + }, }) - return NextResponse.json({ error: errorMessage }, { status: response.status }) + + if (!response.ok) { + const errorMessage = await extractGraphError(response) + logger.error(`[${requestId}] Microsoft Graph API error fetching drives`, { + status: response.status, + error: errorMessage, + }) + return NextResponse.json({ error: errorMessage }, { status: response.status }) + } + + const data = await response.json() + if (Array.isArray(data.value)) { + rawDrives.push(...data.value) + } + + const nextLink = getGraphNextPageUrl(data) + nextUrl = nextLink ? assertGraphNextPageUrl(nextLink) : undefined + if (nextUrl && page === MAX_DRIVES_PAGES - 1) { + logger.warn( + `[${requestId}] Site drives pagination hit ${MAX_DRIVES_PAGES}-page cap; result may be incomplete` + ) + } } - const data = await response.json() - const drives = (data.value || []).map((drive: GraphDrive) => ({ + const drives = rawDrives.map((drive: GraphDrive) => ({ id: drive.id, name: drive.name, driveType: drive.driveType, diff --git a/apps/sim/app/api/tools/microsoft_planner/plans/route.ts b/apps/sim/app/api/tools/microsoft_planner/plans/route.ts index a710f84525..604f7c85b3 100644 --- a/apps/sim/app/api/tools/microsoft_planner/plans/route.ts +++ b/apps/sim/app/api/tools/microsoft_planner/plans/route.ts @@ -6,11 +6,19 @@ import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' +import { assertGraphNextPageUrl, getGraphNextPageUrl } from '@/tools/sharepoint/utils' const logger = createLogger('MicrosoftPlannerPlansAPI') export const dynamic = 'force-dynamic' +/** + * Upper bound on Microsoft Graph pages drained when listing Planner plans. + * Planner uses server-side paging (`$top` is generally ignored), so this caps + * the `@odata.nextLink` follow loop to prevent an unbounded drain. + */ +const MAX_PLANS_PAGES = 20 + export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() @@ -40,25 +48,40 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } - const response = await fetch('https://graph.microsoft.com/v1.0/me/planner/plans', { - headers: { - Authorization: `Bearer ${accessToken}`, - }, - }) + let nextUrl: string | undefined = 'https://graph.microsoft.com/v1.0/me/planner/plans' - if (!response.ok) { - const errorText = await response.text() - logger.error(`[${requestId}] Microsoft Graph API error:`, errorText) - return NextResponse.json( - { error: 'Failed to fetch plans from Microsoft Graph' }, - { status: response.status } - ) + const rawPlans: { id: string; title: string }[] = [] + for (let page = 0; page < MAX_PLANS_PAGES && nextUrl; page++) { + const response = await fetch(nextUrl, { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }) + + if (!response.ok) { + const errorText = await response.text() + logger.error(`[${requestId}] Microsoft Graph API error:`, errorText) + return NextResponse.json( + { error: 'Failed to fetch plans from Microsoft Graph' }, + { status: response.status } + ) + } + + const data = await response.json() + if (Array.isArray(data.value)) { + rawPlans.push(...data.value) + } + + const nextLink = getGraphNextPageUrl(data) + nextUrl = nextLink ? assertGraphNextPageUrl(nextLink) : undefined + if (nextUrl && page === MAX_PLANS_PAGES - 1) { + logger.warn( + `[${requestId}] Planner plans pagination hit ${MAX_PLANS_PAGES}-page cap; result may be incomplete` + ) + } } - const data = await response.json() - const plans = data.value || [] - - const filteredPlans = plans.map((plan: { id: string; title: string }) => ({ + const filteredPlans = rawPlans.map((plan: { id: string; title: string }) => ({ id: plan.id, title: plan.title, })) diff --git a/apps/sim/app/api/tools/microsoft_planner/tasks/route.ts b/apps/sim/app/api/tools/microsoft_planner/tasks/route.ts index 94bf43e832..b9b764089b 100644 --- a/apps/sim/app/api/tools/microsoft_planner/tasks/route.ts +++ b/apps/sim/app/api/tools/microsoft_planner/tasks/route.ts @@ -8,11 +8,19 @@ import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' import type { PlannerTask } from '@/tools/microsoft_planner/types' +import { assertGraphNextPageUrl, getGraphNextPageUrl } from '@/tools/sharepoint/utils' const logger = createLogger('MicrosoftPlannerTasksAPI') export const dynamic = 'force-dynamic' +/** + * Upper bound on Microsoft Graph pages drained when listing a plan's tasks. + * Planner uses server-side paging (`$top` is generally ignored), so this caps + * the `@odata.nextLink` follow loop to prevent an unbounded drain. + */ +const MAX_TASKS_PAGES = 20 + export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() @@ -48,28 +56,41 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } - const response = await fetch( - `https://graph.microsoft.com/v1.0/planner/plans/${planIdValidation.sanitized}/tasks`, - { + let nextUrl: string | undefined = + `https://graph.microsoft.com/v1.0/planner/plans/${planIdValidation.sanitized}/tasks` + + const rawTasks: PlannerTask[] = [] + for (let page = 0; page < MAX_TASKS_PAGES && nextUrl; page++) { + const response = await fetch(nextUrl, { headers: { Authorization: `Bearer ${accessToken}`, }, - } - ) + }) - if (!response.ok) { - const errorText = await response.text() - logger.error(`[${requestId}] Microsoft Graph API error:`, errorText) - return NextResponse.json( - { error: 'Failed to fetch tasks from Microsoft Graph' }, - { status: response.status } - ) + if (!response.ok) { + const errorText = await response.text() + logger.error(`[${requestId}] Microsoft Graph API error:`, errorText) + return NextResponse.json( + { error: 'Failed to fetch tasks from Microsoft Graph' }, + { status: response.status } + ) + } + + const data = await response.json() + if (Array.isArray(data.value)) { + rawTasks.push(...data.value) + } + + const nextLink = getGraphNextPageUrl(data) + nextUrl = nextLink ? assertGraphNextPageUrl(nextLink) : undefined + if (nextUrl && page === MAX_TASKS_PAGES - 1) { + logger.warn( + `[${requestId}] Planner tasks pagination hit ${MAX_TASKS_PAGES}-page cap; result may be incomplete` + ) + } } - const data = await response.json() - const tasks = data.value || [] - - const filteredTasks = tasks.map((task: PlannerTask) => ({ + const filteredTasks = rawTasks.map((task: PlannerTask) => ({ id: task.id, title: task.title, planId: task.planId, diff --git a/apps/sim/app/api/tools/monday/boards/route.ts b/apps/sim/app/api/tools/monday/boards/route.ts index d20634b011..e5d3dc5fad 100644 --- a/apps/sim/app/api/tools/monday/boards/route.ts +++ b/apps/sim/app/api/tools/monday/boards/route.ts @@ -11,18 +11,29 @@ export const dynamic = 'force-dynamic' const logger = createLogger('MondayBoardsAPI') +/** + * Monday's GraphQL `boards(limit: N, page: P, state: active)` has no cursor: + * `page` starts at 1 and you stop once a page returns fewer than `limit` items + * (or an empty page). We request the largest page (`MONDAY_BOARDS_LIMIT`) and + * bound the drain with `MAX_MONDAY_PAGES`. + */ +const MONDAY_BOARDS_LIMIT = 100 +const MAX_MONDAY_PAGES = 50 + interface MondayGraphQLError { message?: string } +interface MondayBoard { + id: string + name: string +} + interface MondayBoardsResponse { errors?: MondayGraphQLError[] error_message?: string data?: { - boards?: Array<{ - id: string - name: string - }> + boards?: MondayBoard[] } } @@ -57,34 +68,68 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } - const response = await fetch('https://api.monday.com/v2', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: accessToken, - 'API-Version': '2024-10', - }, - body: JSON.stringify({ - query: '{ boards(limit: 100, state: active) { id name } }', - }), - }) + const allBoards: MondayBoard[] = [] + let page = 1 - const data = (await response.json()) as MondayBoardsResponse + for (; page <= MAX_MONDAY_PAGES; page++) { + const response = await fetch('https://api.monday.com/v2', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: accessToken, + 'API-Version': '2024-10', + }, + body: JSON.stringify({ + query: `{ boards(limit: ${MONDAY_BOARDS_LIMIT}, page: ${page}, state: active) { id name } }`, + }), + }) - if (data.errors?.length) { - logger.error('Monday.com API error', { errors: data.errors }) - return NextResponse.json( - { error: data.errors[0].message || 'Monday.com API error' }, - { status: 500 } - ) + if (!response.ok) { + const details = await response.text().catch(() => '') + logger.error('Monday.com API HTTP error', { + status: response.status, + statusText: response.statusText, + details, + }) + return NextResponse.json( + { error: `Monday.com API error: ${response.status} ${response.statusText}` }, + { status: 500 } + ) + } + + const data = (await response.json()) as MondayBoardsResponse + + if (data.errors?.length) { + logger.error('Monday.com API error', { errors: data.errors }) + return NextResponse.json( + { error: data.errors[0].message || 'Monday.com API error' }, + { status: 500 } + ) + } + + if (data.error_message) { + logger.error('Monday.com API error', { error_message: data.error_message }) + return NextResponse.json({ error: data.error_message }, { status: 500 }) + } + + const pageBoards = data.data?.boards || [] + allBoards.push(...pageBoards) + + if (pageBoards.length < MONDAY_BOARDS_LIMIT) { + break + } + + if (page === MAX_MONDAY_PAGES) { + logger.warn( + 'Monday boards pagination hit MAX_MONDAY_PAGES cap; board list may be incomplete', + { + maxPages: MAX_MONDAY_PAGES, + } + ) + } } - if (data.error_message) { - logger.error('Monday.com API error', { error_message: data.error_message }) - return NextResponse.json({ error: data.error_message }, { status: 500 }) - } - - const boards = (data.data?.boards || []).map((board) => ({ + const boards = allBoards.map((board) => ({ id: board.id, name: board.name, })) diff --git a/apps/sim/app/api/tools/notion/databases/route.ts b/apps/sim/app/api/tools/notion/databases/route.ts index 6ab772afa9..c3f844495d 100644 --- a/apps/sim/app/api/tools/notion/databases/route.ts +++ b/apps/sim/app/api/tools/notion/databases/route.ts @@ -1,4 +1,5 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' import { notionDatabasesSelectorContract } from '@/lib/api/contracts/selectors' import { parseRequest } from '@/lib/api/server' @@ -12,6 +13,16 @@ const logger = createLogger('NotionDatabasesAPI') export const dynamic = 'force-dynamic' +const NOTION_PAGE_SIZE = 100 + +/** + * Notion's `POST /v1/search` returns at most `page_size` results per call and + * exposes `has_more`/`next_cursor` for pagination. This caps the number of + * pages drained so a tenant with a very large workspace cannot make this route + * loop unbounded. With `NOTION_PAGE_SIZE` of 100 this covers up to 2,000 items. + */ +const MAX_DATABASE_PAGES = 20 + export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() try { @@ -43,33 +54,55 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } - const response = await fetch('https://api.notion.com/v1/search', { - method: 'POST', - headers: { - Authorization: `Bearer ${accessToken}`, - 'Content-Type': 'application/json', - 'Notion-Version': '2022-06-28', - }, - body: JSON.stringify({ - filter: { value: 'database', property: 'object' }, - page_size: 100, - }), - }) + const results: Record[] = [] + let startCursor: string | undefined - if (!response.ok) { - const errorData = await response.json().catch(() => ({})) - logger.error('Failed to fetch Notion databases', { - status: response.status, - error: errorData, + for (let page = 0; page < MAX_DATABASE_PAGES; page++) { + const response = await fetch('https://api.notion.com/v1/search', { + method: 'POST', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + 'Notion-Version': '2022-06-28', + }, + body: JSON.stringify({ + filter: { value: 'database', property: 'object' }, + page_size: NOTION_PAGE_SIZE, + ...(startCursor ? { start_cursor: startCursor } : {}), + }), }) - return NextResponse.json( - { error: 'Failed to fetch Notion databases', details: errorData }, - { status: response.status } - ) + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})) + logger.error('Failed to fetch Notion databases', { + status: response.status, + error: errorData, + }) + return NextResponse.json( + { error: 'Failed to fetch Notion databases', details: errorData }, + { status: response.status } + ) + } + + const data = await response.json() + if (Array.isArray(data.results)) { + results.push(...(data.results as Record[])) + } + + if (!data.has_more || !data.next_cursor) { + break + } + startCursor = data.next_cursor as string + + if (page === MAX_DATABASE_PAGES - 1) { + logger.warn('Notion databases search hit pagination cap; results may be incomplete', { + maxPages: MAX_DATABASE_PAGES, + fetched: results.length, + }) + } } - const data = await response.json() - const databases = (data.results || []).map((db: Record) => ({ + const databases = results.map((db) => ({ id: db.id as string, name: extractTitleFromItem(db), })) @@ -78,7 +111,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } catch (error) { logger.error('Error processing Notion databases request:', error) return NextResponse.json( - { error: 'Failed to retrieve Notion databases', details: (error as Error).message }, + { error: 'Failed to retrieve Notion databases', details: getErrorMessage(error) }, { status: 500 } ) } diff --git a/apps/sim/app/api/tools/notion/pages/route.ts b/apps/sim/app/api/tools/notion/pages/route.ts index 419193fdc7..e48eadf8a4 100644 --- a/apps/sim/app/api/tools/notion/pages/route.ts +++ b/apps/sim/app/api/tools/notion/pages/route.ts @@ -1,4 +1,5 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' import { notionPagesSelectorContract } from '@/lib/api/contracts/selectors' import { parseRequest } from '@/lib/api/server' @@ -12,6 +13,16 @@ const logger = createLogger('NotionPagesAPI') export const dynamic = 'force-dynamic' +const NOTION_PAGE_SIZE = 100 + +/** + * Notion's `POST /v1/search` returns at most `page_size` results per call and + * exposes `has_more`/`next_cursor` for pagination. This caps the number of + * pages drained so a tenant with a very large workspace cannot make this route + * loop unbounded. With `NOTION_PAGE_SIZE` of 100 this covers up to 2,000 items. + */ +const MAX_NOTION_PAGES = 20 + export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() try { @@ -43,33 +54,55 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } - const response = await fetch('https://api.notion.com/v1/search', { - method: 'POST', - headers: { - Authorization: `Bearer ${accessToken}`, - 'Content-Type': 'application/json', - 'Notion-Version': '2022-06-28', - }, - body: JSON.stringify({ - filter: { value: 'page', property: 'object' }, - page_size: 100, - }), - }) + const results: Record[] = [] + let startCursor: string | undefined - if (!response.ok) { - const errorData = await response.json().catch(() => ({})) - logger.error('Failed to fetch Notion pages', { - status: response.status, - error: errorData, + for (let page = 0; page < MAX_NOTION_PAGES; page++) { + const response = await fetch('https://api.notion.com/v1/search', { + method: 'POST', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + 'Notion-Version': '2022-06-28', + }, + body: JSON.stringify({ + filter: { value: 'page', property: 'object' }, + page_size: NOTION_PAGE_SIZE, + ...(startCursor ? { start_cursor: startCursor } : {}), + }), }) - return NextResponse.json( - { error: 'Failed to fetch Notion pages', details: errorData }, - { status: response.status } - ) + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})) + logger.error('Failed to fetch Notion pages', { + status: response.status, + error: errorData, + }) + return NextResponse.json( + { error: 'Failed to fetch Notion pages', details: errorData }, + { status: response.status } + ) + } + + const data = await response.json() + if (Array.isArray(data.results)) { + results.push(...(data.results as Record[])) + } + + if (!data.has_more || !data.next_cursor) { + break + } + startCursor = data.next_cursor as string + + if (page === MAX_NOTION_PAGES - 1) { + logger.warn('Notion pages search hit pagination cap; results may be incomplete', { + maxPages: MAX_NOTION_PAGES, + fetched: results.length, + }) + } } - const data = await response.json() - const pages = (data.results || []).map((page: Record) => ({ + const pages = results.map((page) => ({ id: page.id as string, name: extractTitleFromItem(page), })) @@ -78,7 +111,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } catch (error) { logger.error('Error processing Notion pages request:', error) return NextResponse.json( - { error: 'Failed to retrieve Notion pages', details: (error as Error).message }, + { error: 'Failed to retrieve Notion pages', details: getErrorMessage(error) }, { status: 500 } ) } diff --git a/apps/sim/app/api/tools/onedrive/files/route.ts b/apps/sim/app/api/tools/onedrive/files/route.ts index 4b3b727360..5bf2a580ac 100644 --- a/apps/sim/app/api/tools/onedrive/files/route.ts +++ b/apps/sim/app/api/tools/onedrive/files/route.ts @@ -8,11 +8,21 @@ import { validateMicrosoftGraphId } from '@/lib/core/security/input-validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' import type { MicrosoftGraphDriveItem } from '@/tools/onedrive/types' +import { assertGraphNextPageUrl, getGraphNextPageUrl } from '@/tools/sharepoint/utils' export const dynamic = 'force-dynamic' const logger = createLogger('OneDriveFilesAPI') +/** + * Microsoft Graph paginates drive item collections via the `@odata.nextLink` + * absolute URL in the response body. Request the largest page (`$top` caps at + * 999) and drain following nextLink, bounded by a page cap. + * See https://learn.microsoft.com/en-us/graph/paging + */ +const ONEDRIVE_FILES_PAGE_SIZE = 999 +const MAX_ONEDRIVE_FILES_PAGES = 20 + /** * Get files (not folders) from Microsoft OneDrive */ @@ -71,7 +81,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { '$select', 'id,name,file,webUrl,size,createdDateTime,lastModifiedDateTime,createdBy,thumbnails' ) - searchParams_new.append('$top', '50') + searchParams_new.append('$top', String(ONEDRIVE_FILES_PAGE_SIZE)) url = `https://graph.microsoft.com/v1.0/me/drive/root/search(q='${encodeURIComponent(query)}')?${searchParams_new.toString()}` } else { const searchParams_new = new URLSearchParams() @@ -79,34 +89,53 @@ export const GET = withRouteHandler(async (request: NextRequest) => { '$select', 'id,name,file,folder,webUrl,size,createdDateTime,lastModifiedDateTime,createdBy,thumbnails' ) - searchParams_new.append('$top', '50') + searchParams_new.append('$top', String(ONEDRIVE_FILES_PAGE_SIZE)) url = `https://graph.microsoft.com/v1.0/me/drive/root/children?${searchParams_new.toString()}` } logger.info(`[${requestId}] Fetching files from Microsoft Graph`, { url }) - const response = await fetch(url, { - headers: { - Authorization: `Bearer ${accessToken}`, - }, - }) + const rawItems: MicrosoftGraphDriveItem[] = [] + let nextUrl: string | undefined = url - if (!response.ok) { - const errorData = await response.json().catch(() => ({ error: { message: 'Unknown error' } })) - logger.error(`[${requestId}] Microsoft Graph API error`, { - status: response.status, - error: errorData.error?.message || 'Failed to fetch files from OneDrive', + for (let page = 0; page < MAX_ONEDRIVE_FILES_PAGES && nextUrl; page++) { + const response = await fetch(nextUrl, { + headers: { + Authorization: `Bearer ${accessToken}`, + }, }) - return NextResponse.json( - { error: errorData.error?.message || 'Failed to fetch files from OneDrive' }, - { status: response.status } - ) + + if (!response.ok) { + const errorData = await response + .json() + .catch(() => ({ error: { message: 'Unknown error' } })) + logger.error(`[${requestId}] Microsoft Graph API error`, { + status: response.status, + error: errorData.error?.message || 'Failed to fetch files from OneDrive', + }) + return NextResponse.json( + { error: errorData.error?.message || 'Failed to fetch files from OneDrive' }, + { status: response.status } + ) + } + + const data = await response.json() + rawItems.push(...((data.value as MicrosoftGraphDriveItem[]) || [])) + + const nextLink = getGraphNextPageUrl(data) + nextUrl = nextLink ? assertGraphNextPageUrl(nextLink) : undefined + + if (nextUrl && page === MAX_ONEDRIVE_FILES_PAGES - 1) { + logger.warn(`[${requestId}] OneDrive files hit pagination cap; list may be incomplete`, { + pages: MAX_ONEDRIVE_FILES_PAGES, + collected: rawItems.length, + }) + } } - const data = await response.json() - logger.info(`[${requestId}] Received ${data.value?.length || 0} items from Microsoft Graph`) + logger.info(`[${requestId}] Received ${rawItems.length} items from Microsoft Graph`) - const files = (data.value || []) + const files = rawItems .filter((item: MicrosoftGraphDriveItem) => !!item.file && !item.folder) .map((file: MicrosoftGraphDriveItem) => ({ id: file.id, @@ -129,7 +158,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { })) logger.info(`[${requestId}] Returning ${files.length} files`, { - totalItems: data.value?.length || 0, + totalItems: rawItems.length, }) return NextResponse.json({ files }, { status: 200 }) diff --git a/apps/sim/app/api/tools/onedrive/folders/route.ts b/apps/sim/app/api/tools/onedrive/folders/route.ts index 4c65c4190f..bcfd9273c2 100644 --- a/apps/sim/app/api/tools/onedrive/folders/route.ts +++ b/apps/sim/app/api/tools/onedrive/folders/route.ts @@ -8,11 +8,21 @@ import { validateMicrosoftGraphId } from '@/lib/core/security/input-validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' import type { MicrosoftGraphDriveItem } from '@/tools/onedrive/types' +import { assertGraphNextPageUrl, getGraphNextPageUrl } from '@/tools/sharepoint/utils' export const dynamic = 'force-dynamic' const logger = createLogger('OneDriveFoldersAPI') +/** + * Microsoft Graph paginates drive item collections via the `@odata.nextLink` + * absolute URL in the response body. Request the largest page (`$top` caps at + * 999) and drain following nextLink, bounded by a page cap. + * See https://learn.microsoft.com/en-us/graph/paging + */ +const ONEDRIVE_FOLDERS_PAGE_SIZE = 999 +const MAX_ONEDRIVE_FOLDERS_PAGES = 20 + /** * Get folders from Microsoft OneDrive */ @@ -60,28 +70,47 @@ export const GET = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 }) } - let url = `https://graph.microsoft.com/v1.0/me/drive/root/children?$filter=folder ne null&$select=id,name,folder,webUrl,createdDateTime,lastModifiedDateTime&$top=50` + let url = `https://graph.microsoft.com/v1.0/me/drive/root/children?$filter=folder ne null&$select=id,name,folder,webUrl,createdDateTime,lastModifiedDateTime&$top=${ONEDRIVE_FOLDERS_PAGE_SIZE}` if (query) { url += `&$search="${encodeURIComponent(query)}"` } - const response = await fetch(url, { - headers: { - Authorization: `Bearer ${accessToken}`, - }, - }) + const rawItems: MicrosoftGraphDriveItem[] = [] + let nextUrl: string | undefined = url - if (!response.ok) { - const errorData = await response.json().catch(() => ({ error: { message: 'Unknown error' } })) - return NextResponse.json( - { error: errorData.error?.message || 'Failed to fetch folders from OneDrive' }, - { status: response.status } - ) + for (let page = 0; page < MAX_ONEDRIVE_FOLDERS_PAGES && nextUrl; page++) { + const response = await fetch(nextUrl, { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }) + + if (!response.ok) { + const errorData = await response + .json() + .catch(() => ({ error: { message: 'Unknown error' } })) + return NextResponse.json( + { error: errorData.error?.message || 'Failed to fetch folders from OneDrive' }, + { status: response.status } + ) + } + + const data = await response.json() + rawItems.push(...((data.value as MicrosoftGraphDriveItem[]) || [])) + + const nextLink = getGraphNextPageUrl(data) + nextUrl = nextLink ? assertGraphNextPageUrl(nextLink) : undefined + + if (nextUrl && page === MAX_ONEDRIVE_FOLDERS_PAGES - 1) { + logger.warn(`[${requestId}] OneDrive folders hit pagination cap; list may be incomplete`, { + pages: MAX_ONEDRIVE_FOLDERS_PAGES, + collected: rawItems.length, + }) + } } - const data = await response.json() - const folders = (data.value || []) + const folders = rawItems .filter((item: MicrosoftGraphDriveItem) => item.folder) .map((folder: MicrosoftGraphDriveItem) => ({ id: folder.id, diff --git a/apps/sim/app/api/tools/outlook/folders/route.ts b/apps/sim/app/api/tools/outlook/folders/route.ts index 2cd0addcd8..8ae9d3e9e2 100644 --- a/apps/sim/app/api/tools/outlook/folders/route.ts +++ b/apps/sim/app/api/tools/outlook/folders/route.ts @@ -8,11 +8,21 @@ import { validateAlphanumericId } from '@/lib/core/security/input-validation' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' +import { assertGraphNextPageUrl, getGraphNextPageUrl } from '@/tools/sharepoint/utils' export const dynamic = 'force-dynamic' const logger = createLogger('OutlookFoldersAPI') +/** + * Microsoft Graph paginates `mailFolders` via the `@odata.nextLink` absolute + * URL in the response body (default page size is ~10). Bound the drain so a + * pathological account can't loop unbounded; `$top` is capped at 999 by Graph. + * See https://learn.microsoft.com/en-us/graph/paging + */ +const OUTLOOK_FOLDERS_PAGE_SIZE = 999 +const MAX_OUTLOOK_FOLDERS_PAGES = 20 + interface OutlookFolder { id: string displayName: string @@ -65,37 +75,53 @@ export const GET = withRouteHandler(async (request: NextRequest) => { ) } - const response = await fetch('https://graph.microsoft.com/v1.0/me/mailFolders', { - method: 'GET', - headers: { - Authorization: `Bearer ${accessToken}`, - 'Content-Type': 'application/json', - }, - }) + const folders: OutlookFolder[] = [] + let nextUrl: string | undefined = + `https://graph.microsoft.com/v1.0/me/mailFolders?$top=${OUTLOOK_FOLDERS_PAGE_SIZE}` - if (!response.ok) { - const errorData = await response.json() - logger.error('Microsoft Graph API error getting folders', { - status: response.status, - error: errorData, - endpoint: 'https://graph.microsoft.com/v1.0/me/mailFolders', + for (let page = 0; page < MAX_OUTLOOK_FOLDERS_PAGES && nextUrl; page++) { + const response = await fetch(nextUrl, { + method: 'GET', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, }) - if (response.status === 401) { - return NextResponse.json( - { - error: 'Authentication failed. Please reconnect your Outlook account.', - authRequired: true, - }, - { status: 401 } - ) + if (!response.ok) { + const errorData = await response.json() + logger.error('Microsoft Graph API error getting folders', { + status: response.status, + error: errorData, + endpoint: nextUrl, + }) + + if (response.status === 401) { + return NextResponse.json( + { + error: 'Authentication failed. Please reconnect your Outlook account.', + authRequired: true, + }, + { status: 401 } + ) + } + + throw new Error(`Microsoft Graph API error: ${JSON.stringify(errorData)}`) } - throw new Error(`Microsoft Graph API error: ${JSON.stringify(errorData)}`) - } + const data = await response.json() + folders.push(...((data.value as OutlookFolder[]) || [])) - const data = await response.json() - const folders = data.value || [] + const nextLink = getGraphNextPageUrl(data) + nextUrl = nextLink ? assertGraphNextPageUrl(nextLink) : undefined + + if (nextUrl && page === MAX_OUTLOOK_FOLDERS_PAGES - 1) { + logger.warn('Outlook mailFolders hit pagination cap; folder list may be incomplete', { + pages: MAX_OUTLOOK_FOLDERS_PAGES, + collected: folders.length, + }) + } + } const transformedFolders = folders.map((folder: OutlookFolder) => ({ id: folder.id, diff --git a/apps/sim/app/api/tools/pipedrive/pipelines/route.ts b/apps/sim/app/api/tools/pipedrive/pipelines/route.ts index 8e3900fe11..707a9ff9ee 100644 --- a/apps/sim/app/api/tools/pipedrive/pipelines/route.ts +++ b/apps/sim/app/api/tools/pipedrive/pipelines/route.ts @@ -11,6 +11,86 @@ const logger = createLogger('PipedrivePipelinesAPI') export const dynamic = 'force-dynamic' +const PIPEDRIVE_PAGE_LIMIT = 500 +const PIPEDRIVE_MAX_PIPELINES_PAGES = 50 + +interface PipedrivePipeline { + id: number + name: string +} + +interface PipedrivePipelinesPage { + data?: PipedrivePipeline[] + additional_data?: { + pagination?: { + more_items_in_collection?: boolean + next_start?: number + } + } +} + +/** + * Lists all Pipedrive pipelines using v1 offset pagination (`start`/`limit`), + * following `additional_data.pagination.next_start` while + * `more_items_in_collection` is true so the full set is returned. Bounded by + * `PIPEDRIVE_MAX_PIPELINES_PAGES`; logs a warning rather than silently dropping + * pipelines when the cap is hit. + */ +async function fetchAllPipelines(accessToken: string): Promise { + const pipelines: PipedrivePipeline[] = [] + let start = 0 + + for (let page = 0; page < PIPEDRIVE_MAX_PIPELINES_PAGES; page++) { + const url = new URL('https://api.pipedrive.com/v1/pipelines') + url.searchParams.set('start', String(start)) + url.searchParams.set('limit', String(PIPEDRIVE_PAGE_LIMIT)) + + const response = await fetch(url.toString(), { + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + }) + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})) + throw new PipedriveFetchError(response.status, errorData) + } + + const data = (await response.json()) as PipedrivePipelinesPage + if (Array.isArray(data.data)) { + pipelines.push(...data.data) + } + + const pagination = data.additional_data?.pagination + if (!pagination?.more_items_in_collection || typeof pagination.next_start !== 'number') { + return pipelines + } + start = pagination.next_start + + if (page === PIPEDRIVE_MAX_PIPELINES_PAGES - 1) { + logger.warn( + 'Pipedrive pipelines listing hit pagination cap; pipeline list may be incomplete', + { + pages: PIPEDRIVE_MAX_PIPELINES_PAGES, + } + ) + } + } + + return pipelines +} + +class PipedriveFetchError extends Error { + constructor( + readonly status: number, + readonly details: unknown + ) { + super('Failed to fetch Pipedrive pipelines') + this.name = 'PipedriveFetchError' + } +} + export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() try { @@ -42,27 +122,24 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } - const response = await fetch('https://api.pipedrive.com/v1/pipelines', { - headers: { - Authorization: `Bearer ${accessToken}`, - 'Content-Type': 'application/json', - }, - }) - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})) - logger.error('Failed to fetch Pipedrive pipelines', { - status: response.status, - error: errorData, - }) - return NextResponse.json( - { error: 'Failed to fetch Pipedrive pipelines', details: errorData }, - { status: response.status } - ) + let allPipelines: PipedrivePipeline[] + try { + allPipelines = await fetchAllPipelines(accessToken) + } catch (error) { + if (error instanceof PipedriveFetchError) { + logger.error('Failed to fetch Pipedrive pipelines', { + status: error.status, + error: error.details, + }) + return NextResponse.json( + { error: 'Failed to fetch Pipedrive pipelines', details: error.details }, + { status: error.status } + ) + } + throw error } - const data = await response.json() - const pipelines = (data.data || []).map((pipeline: { id: number; name: string }) => ({ + const pipelines = allPipelines.map((pipeline) => ({ id: String(pipeline.id), name: pipeline.name, })) diff --git a/apps/sim/app/api/tools/sharepoint/lists/route.ts b/apps/sim/app/api/tools/sharepoint/lists/route.ts index 109b410678..a3970a6f04 100644 --- a/apps/sim/app/api/tools/sharepoint/lists/route.ts +++ b/apps/sim/app/api/tools/sharepoint/lists/route.ts @@ -7,11 +7,19 @@ import { validateSharePointSiteId } from '@/lib/core/security/input-validation' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' +import { assertGraphNextPageUrl, getGraphNextPageUrl } from '@/tools/sharepoint/utils' export const dynamic = 'force-dynamic' const logger = createLogger('SharePointListsAPI') +/** + * Upper bound on Microsoft Graph pages drained when listing SharePoint lists. + * Each page returns up to `$top=999` lists, so this caps the result set at + * roughly 10k lists while preventing an unbounded server-side loop. + */ +const MAX_LISTS_PAGES = 10 + interface SharePointList { id: string displayName: string @@ -60,24 +68,42 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } - const url = `https://graph.microsoft.com/v1.0/sites/${siteIdValidation.sanitized}/lists?$select=id,displayName,description,webUrl&$expand=list($select=hidden)&$top=100` + let nextUrl: string | undefined = + `https://graph.microsoft.com/v1.0/sites/${siteIdValidation.sanitized}/lists?$select=id,displayName,description,webUrl&$expand=list($select=hidden)&$top=999` - const response = await fetch(url, { - headers: { - Authorization: `Bearer ${accessToken}`, - }, - }) + const rawLists: SharePointList[] = [] + for (let page = 0; page < MAX_LISTS_PAGES && nextUrl; page++) { + const response = await fetch(nextUrl, { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }) - if (!response.ok) { - const errorData = await response.json().catch(() => ({ error: { message: 'Unknown error' } })) - return NextResponse.json( - { error: errorData.error?.message || 'Failed to fetch lists from SharePoint' }, - { status: response.status } - ) + if (!response.ok) { + const errorData = await response + .json() + .catch(() => ({ error: { message: 'Unknown error' } })) + return NextResponse.json( + { error: errorData.error?.message || 'Failed to fetch lists from SharePoint' }, + { status: response.status } + ) + } + + const data = await response.json() + if (Array.isArray(data.value)) { + rawLists.push(...data.value) + } + + const nextLink = getGraphNextPageUrl(data) + nextUrl = nextLink ? assertGraphNextPageUrl(nextLink) : undefined + if (nextUrl && page === MAX_LISTS_PAGES - 1) { + logger.warn( + `[${requestId}] SharePoint lists pagination hit ${MAX_LISTS_PAGES}-page cap; result may be incomplete` + ) + } } - const data = await response.json() - const lists = (data.value || []) + const lists = rawLists .filter((list: SharePointList) => list.list?.hidden !== true) .map((list: SharePointList) => ({ id: list.id, diff --git a/apps/sim/app/api/tools/sharepoint/sites/route.ts b/apps/sim/app/api/tools/sharepoint/sites/route.ts index 4ca0c58cde..fc8db948c7 100644 --- a/apps/sim/app/api/tools/sharepoint/sites/route.ts +++ b/apps/sim/app/api/tools/sharepoint/sites/route.ts @@ -7,11 +7,19 @@ import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' import type { SharepointSite } from '@/tools/sharepoint/types' +import { assertGraphNextPageUrl, getGraphNextPageUrl } from '@/tools/sharepoint/utils' export const dynamic = 'force-dynamic' const logger = createLogger('SharePointSitesAPI') +/** + * Upper bound on Microsoft Graph pages drained when listing SharePoint sites. + * Each page returns up to `$top=999` sites, so this caps the result set at + * roughly 10k sites while preventing an unbounded server-side loop. + */ +const MAX_SITES_PAGES = 10 + export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() @@ -45,24 +53,42 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } const searchQuery = query || '*' - const url = `https://graph.microsoft.com/v1.0/sites?search=${encodeURIComponent(searchQuery)}&$select=id,name,displayName,webUrl,createdDateTime,lastModifiedDateTime&$top=50` + let nextUrl: string | undefined = + `https://graph.microsoft.com/v1.0/sites?search=${encodeURIComponent(searchQuery)}&$select=id,name,displayName,webUrl,createdDateTime,lastModifiedDateTime&$top=999` - const response = await fetch(url, { - headers: { - Authorization: `Bearer ${accessToken}`, - }, - }) + const rawSites: SharepointSite[] = [] + for (let page = 0; page < MAX_SITES_PAGES && nextUrl; page++) { + const response = await fetch(nextUrl, { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }) - if (!response.ok) { - const errorData = await response.json().catch(() => ({ error: { message: 'Unknown error' } })) - return NextResponse.json( - { error: errorData.error?.message || 'Failed to fetch sites from SharePoint' }, - { status: response.status } - ) + if (!response.ok) { + const errorData = await response + .json() + .catch(() => ({ error: { message: 'Unknown error' } })) + return NextResponse.json( + { error: errorData.error?.message || 'Failed to fetch sites from SharePoint' }, + { status: response.status } + ) + } + + const data = await response.json() + if (Array.isArray(data.value)) { + rawSites.push(...data.value) + } + + const nextLink = getGraphNextPageUrl(data) + nextUrl = nextLink ? assertGraphNextPageUrl(nextLink) : undefined + if (nextUrl && page === MAX_SITES_PAGES - 1) { + logger.warn( + `[${requestId}] SharePoint sites pagination hit ${MAX_SITES_PAGES}-page cap; result may be incomplete` + ) + } } - const data = await response.json() - const sites = (data.value || []).map((site: SharepointSite) => ({ + const sites = rawSites.map((site: SharepointSite) => ({ id: site.id, name: site.displayName || site.name, mimeType: 'application/vnd.microsoft.graph.site', diff --git a/apps/sim/app/api/tools/slack/users/route.ts b/apps/sim/app/api/tools/slack/users/route.ts index d9360d9b7c..cb1e69569f 100644 --- a/apps/sim/app/api/tools/slack/users/route.ts +++ b/apps/sim/app/api/tools/slack/users/route.ts @@ -12,6 +12,9 @@ export const dynamic = 'force-dynamic' const logger = createLogger('SlackUsersAPI') +const SLACK_PAGE_LIMIT = 200 +const SLACK_MAX_USER_PAGES = 10 + interface SlackUser { id: string name: string @@ -20,6 +23,11 @@ interface SlackUser { is_bot: boolean } +interface SlackUsersResult { + members: SlackUser[] + truncated: boolean +} + export const POST = withRouteHandler(async (request: NextRequest) => { try { const requestId = generateRequestId() @@ -86,6 +94,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } const data = await fetchSlackUsers(accessToken) + if (data.truncated) { + logger.warn('users.list hit pagination cap; user list may be incomplete') + } const users = (data.members || []) .filter((user: SlackUser) => !user.deleted && !user.is_bot) @@ -134,27 +145,53 @@ async function fetchSlackUser(accessToken: string, userId: string) { return data } -async function fetchSlackUsers(accessToken: string) { - const url = new URL('https://slack.com/api/users.list') - url.searchParams.append('limit', '200') +/** + * Lists Slack workspace members, following `response_metadata.next_cursor` so + * the full set is returned. Bounded by `SLACK_MAX_USER_PAGES`; sets `truncated` + * rather than silently dropping members when the cap is hit. + */ +async function fetchSlackUsers(accessToken: string): Promise { + const members: SlackUser[] = [] + let cursor: string | undefined + let truncated = false - const response = await fetch(url.toString(), { - method: 'GET', - headers: { - Authorization: `Bearer ${accessToken}`, - 'Content-Type': 'application/json', - }, - }) + for (let page = 0; page < SLACK_MAX_USER_PAGES; page++) { + const url = new URL('https://slack.com/api/users.list') + url.searchParams.append('limit', String(SLACK_PAGE_LIMIT)) + if (cursor) { + url.searchParams.append('cursor', cursor) + } - if (!response.ok) { - throw new Error(`Slack API error: ${response.status} ${response.statusText}`) + const response = await fetch(url.toString(), { + method: 'GET', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + }) + + if (!response.ok) { + throw new Error(`Slack API error: ${response.status} ${response.statusText}`) + } + + const data = await response.json() + + if (!data.ok) { + throw new Error(data.error || 'Failed to fetch users') + } + + if (Array.isArray(data.members)) { + members.push(...data.members) + } + + cursor = data.response_metadata?.next_cursor?.trim() || undefined + if (!cursor) { + return { members, truncated } + } + if (page === SLACK_MAX_USER_PAGES - 1) { + truncated = true + } } - const data = await response.json() - - if (!data.ok) { - throw new Error(data.error || 'Failed to fetch users') - } - - return data + return { members, truncated } } diff --git a/apps/sim/app/api/tools/wealthbox/items/route.ts b/apps/sim/app/api/tools/wealthbox/items/route.ts index 00ce1aab98..a10c4672eb 100644 --- a/apps/sim/app/api/tools/wealthbox/items/route.ts +++ b/apps/sim/app/api/tools/wealthbox/items/route.ts @@ -12,6 +12,18 @@ export const dynamic = 'force-dynamic' const logger = createLogger('WealthboxItemsAPI') +/** + * Wealthbox `GET /v1/contacts` paginates with `?page=` / `?per_page=`, starting + * at page 1. Wealthbox documents no `per_page` maximum and its contacts response + * carries no pagination `meta`, so termination relies on the short-page check: + * we stop once a page returns fewer items than `WEALTHBOX_PAGE_SIZE` (the + * `meta.total_pages` / `meta.current_page` check is a defensive fallback for if + * Wealthbox ever adds that block). Bounded by `MAX_WEALTHBOX_PAGES` so a runaway + * response can't loop forever. + */ +const WEALTHBOX_PAGE_SIZE = 50 +const MAX_WEALTHBOX_PAGES = 50 + interface WealthboxItem { id: string name: string @@ -21,6 +33,15 @@ interface WealthboxItem { updatedAt: string } +interface WealthboxContactsPage { + contacts?: Array> + meta?: { + total_count?: number + total_pages?: number + current_page?: number + } +} + /** * Get items (notes, contacts, tasks) from Wealthbox */ @@ -69,63 +90,83 @@ export const GET = withRouteHandler(async (request: NextRequest) => { } const endpoint = endpoints[type as keyof typeof endpoints] - const url = new URL(`https://api.crmworkspace.com/v1/${endpoint}`) - logger.info(`[${requestId}] Fetching ${type}s from Wealthbox`, { endpoint, - url: url.toString(), hasQuery: !!query.trim(), }) - const response = await fetch(url.toString(), { - headers: { - Authorization: `Bearer ${accessToken}`, - 'Content-Type': 'application/json', - }, - }) + const allContacts: Array> = [] + let page = 1 - if (!response.ok) { - const errorText = await response.text() - logger.error( - `[${requestId}] Wealthbox API error: ${response.status} ${response.statusText}`, - { - error: errorText, - endpoint, - url: url.toString(), - } - ) - return NextResponse.json( - { error: `Failed to fetch ${type}s from Wealthbox` }, - { status: response.status } - ) - } + for (; page <= MAX_WEALTHBOX_PAGES; page++) { + const url = new URL(`https://api.crmworkspace.com/v1/${endpoint}`) + url.searchParams.set('per_page', String(WEALTHBOX_PAGE_SIZE)) + url.searchParams.set('page', String(page)) - const data = (await response.json()) as { contacts?: Array> } & Record< - string, - unknown - > + const response = await fetch(url.toString(), { + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + }) - logger.info(`[${requestId}] Wealthbox API raw response`, { - type, - status: response.status, - dataKeys: Object.keys(data || {}), - hasContacts: !!data.contacts, - dataStructure: typeof data === 'object' ? Object.keys(data) : 'not an object', - }) + if (!response.ok) { + const errorText = await response.text() + logger.error( + `[${requestId}] Wealthbox API error: ${response.status} ${response.statusText}`, + { + error: errorText, + endpoint, + url: url.toString(), + } + ) + return NextResponse.json( + { error: `Failed to fetch ${type}s from Wealthbox` }, + { status: response.status } + ) + } - let items: WealthboxItem[] = [] + const data = (await response.json()) as WealthboxContactsPage - if (type === 'contact') { const contacts = data.contacts || [] if (!Array.isArray(contacts)) { logger.warn(`[${requestId}] Contacts is not an array`, { contacts, dataType: typeof contacts, }) - return NextResponse.json({ items: [] }, { status: 200 }) + break } - items = contacts.map((item) => { + allContacts.push(...contacts) + + const totalPages = data.meta?.total_pages + const currentPage = data.meta?.current_page ?? page + const reachedLastByMeta = + typeof totalPages === 'number' && totalPages > 0 && currentPage >= totalPages + const reachedLastByCount = contacts.length < WEALTHBOX_PAGE_SIZE + + if (reachedLastByMeta || reachedLastByCount) { + break + } + + if (page === MAX_WEALTHBOX_PAGES) { + logger.warn( + `[${requestId}] Wealthbox pagination hit MAX_WEALTHBOX_PAGES cap; contact list may be incomplete`, + { endpoint, maxPages: MAX_WEALTHBOX_PAGES } + ) + } + } + + logger.info(`[${requestId}] Wealthbox API drained`, { + type, + pagesFetched: Math.min(page, MAX_WEALTHBOX_PAGES), + totalContacts: allContacts.length, + }) + + let items: WealthboxItem[] = [] + + if (type === 'contact') { + items = allContacts.map((item) => { const firstName = typeof item.first_name === 'string' ? item.first_name : '' const lastName = typeof item.last_name === 'string' ? item.last_name : '' return { diff --git a/apps/sim/app/api/tools/webflow/items/route.ts b/apps/sim/app/api/tools/webflow/items/route.ts index 1ed9884e0f..4feb0f4041 100644 --- a/apps/sim/app/api/tools/webflow/items/route.ts +++ b/apps/sim/app/api/tools/webflow/items/route.ts @@ -12,6 +12,9 @@ const logger = createLogger('WebflowItemsAPI') export const dynamic = 'force-dynamic' +const WEBFLOW_PAGE_LIMIT = 100 +const WEBFLOW_MAX_ITEMS_PAGES = 50 + interface WebflowItem { id: string fieldData?: { @@ -21,6 +24,74 @@ interface WebflowItem { } } +interface WebflowItemsPage { + items?: WebflowItem[] + pagination?: { + total?: number + limit?: number + offset?: number + } +} + +/** + * Lists all items in a Webflow collection using `offset`/`limit` pagination + * (limit capped at 100), advancing the numeric `offset` until the accumulated + * count reaches `pagination.total` so the full set is returned. Bounded by + * `WEBFLOW_MAX_ITEMS_PAGES`; logs a warning rather than silently dropping items + * when the cap is hit. + */ +async function fetchAllItems(accessToken: string, collectionId: string): Promise { + const items: WebflowItem[] = [] + let offset = 0 + + for (let page = 0; page < WEBFLOW_MAX_ITEMS_PAGES; page++) { + const url = new URL(`https://api.webflow.com/v2/collections/${collectionId}/items`) + url.searchParams.set('limit', String(WEBFLOW_PAGE_LIMIT)) + url.searchParams.set('offset', String(offset)) + + const response = await fetch(url.toString(), { + headers: { + Authorization: `Bearer ${accessToken}`, + accept: 'application/json', + }, + }) + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})) + throw new WebflowFetchError(response.status, errorData) + } + + const data = (await response.json()) as WebflowItemsPage + const pageItems = data.items || [] + items.push(...pageItems) + + const total = data.pagination?.total + offset += pageItems.length + if (pageItems.length === 0 || (typeof total === 'number' && items.length >= total)) { + return items + } + + if (page === WEBFLOW_MAX_ITEMS_PAGES - 1) { + logger.warn('Webflow items listing hit pagination cap; item list may be incomplete', { + collectionId, + pages: WEBFLOW_MAX_ITEMS_PAGES, + }) + } + } + + return items +} + +class WebflowFetchError extends Error { + constructor( + readonly status: number, + readonly details: unknown + ) { + super('Failed to fetch Webflow items') + this.name = 'WebflowFetchError' + } +} + export const POST = withRouteHandler(async (request: NextRequest) => { try { const requestId = generateRequestId() @@ -61,32 +132,24 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } - const response = await fetch( - `https://api.webflow.com/v2/collections/${collectionId}/items?limit=100`, - { - headers: { - Authorization: `Bearer ${accessToken}`, - accept: 'application/json', - }, + let items: WebflowItem[] + try { + items = await fetchAllItems(accessToken, collectionId) + } catch (error) { + if (error instanceof WebflowFetchError) { + logger.error('Failed to fetch Webflow items', { + status: error.status, + error: error.details, + collectionId, + }) + return NextResponse.json( + { error: 'Failed to fetch Webflow items', details: error.details }, + { status: error.status } + ) } - ) - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})) - logger.error('Failed to fetch Webflow items', { - status: response.status, - error: errorData, - collectionId, - }) - return NextResponse.json( - { error: 'Failed to fetch Webflow items', details: errorData }, - { status: response.status } - ) + throw error } - const data = (await response.json()) as { items?: WebflowItem[] } - const items = data.items || [] - let formattedItems = items.map((item) => { const fieldData = item.fieldData || {} const name = fieldData.name || fieldData.title || fieldData.slug || item.id diff --git a/apps/sim/app/api/tools/zoom/meetings/route.ts b/apps/sim/app/api/tools/zoom/meetings/route.ts index 36edf49878..53e78f408c 100644 --- a/apps/sim/app/api/tools/zoom/meetings/route.ts +++ b/apps/sim/app/api/tools/zoom/meetings/route.ts @@ -11,6 +11,24 @@ const logger = createLogger('ZoomMeetingsAPI') export const dynamic = 'force-dynamic' +/** + * Zoom `GET /v2/users/me/meetings` returns `next_page_token`, which is passed + * back as `?next_page_token=` until it comes back as an empty string. `page_size` + * max is 300. Bounded by `MAX_ZOOM_PAGES` so a runaway response can't loop forever. + */ +const ZOOM_PAGE_SIZE = 300 +const MAX_ZOOM_PAGES = 50 + +interface ZoomMeeting { + id: number + topic: string +} + +interface ZoomMeetingsPage { + meetings?: ZoomMeeting[] + next_page_token?: string +} + export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() try { @@ -42,30 +60,57 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } - const response = await fetch( - 'https://api.zoom.us/v2/users/me/meetings?page_size=300&type=scheduled', - { + const allMeetings: ZoomMeeting[] = [] + let nextPageToken = '' + + for (let page = 0; page < MAX_ZOOM_PAGES; page++) { + const url = new URL('https://api.zoom.us/v2/users/me/meetings') + url.searchParams.set('page_size', String(ZOOM_PAGE_SIZE)) + url.searchParams.set('type', 'scheduled') + if (nextPageToken) { + url.searchParams.set('next_page_token', nextPageToken) + } + + const response = await fetch(url.toString(), { headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json', }, - } - ) - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})) - logger.error('Failed to fetch Zoom meetings', { - status: response.status, - error: errorData, }) - return NextResponse.json( - { error: 'Failed to fetch Zoom meetings', details: errorData }, - { status: response.status } - ) + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})) + logger.error('Failed to fetch Zoom meetings', { + status: response.status, + error: errorData, + }) + return NextResponse.json( + { error: 'Failed to fetch Zoom meetings', details: errorData }, + { status: response.status } + ) + } + + const data = (await response.json()) as ZoomMeetingsPage + if (Array.isArray(data.meetings)) { + allMeetings.push(...data.meetings) + } + + nextPageToken = data.next_page_token?.trim() || '' + if (!nextPageToken) { + break + } + + if (page === MAX_ZOOM_PAGES - 1) { + logger.warn( + 'Zoom meetings pagination hit MAX_ZOOM_PAGES cap; meeting list may be incomplete', + { + maxPages: MAX_ZOOM_PAGES, + } + ) + } } - const data = await response.json() - const meetings = (data.meetings || []).map((meeting: { id: number; topic: string }) => ({ + const meetings = allMeetings.map((meeting) => ({ id: String(meeting.id), name: meeting.topic, })) diff --git a/apps/sim/hooks/queries/workflow-search-replace.ts b/apps/sim/hooks/queries/workflow-search-replace.ts index 0dd264e6e4..02e3bf1d50 100644 --- a/apps/sim/hooks/queries/workflow-search-replace.ts +++ b/apps/sim/hooks/queries/workflow-search-replace.ts @@ -33,7 +33,7 @@ import { fetchOAuthCredentialDetail, fetchOAuthCredentials, } from '@/hooks/queries/oauth/oauth-credentials' -import { getSelectorDefinition } from '@/hooks/selectors/registry' +import { getSelectorDefinition, loadAllSelectorOptions } from '@/hooks/selectors/registry' import type { SelectorKey, SelectorOption } from '@/hooks/selectors/types' export interface WorkflowSearchResolvedResource { @@ -374,7 +374,11 @@ export function useWorkflowSearchSelectorDetails(matches: WorkflowSearchMatch[]) return definition.fetchById({ ...queryArgs, signal }) } - const options = await definition.fetchList({ key: selectorKey, context, signal }) + const options = await loadAllSelectorOptions(definition, { + key: selectorKey, + context, + signal, + }) return options.find((option) => option.id === match.rawValue) ?? null }, enabled: Boolean(selectorKey && match.rawValue && baseEnabled), @@ -620,7 +624,7 @@ export function useWorkflowSearchSelectorReplacementOptions(matches: WorkflowSea return { queryKey: workflowSearchReplaceKeys.selectorReplacementOptions(selectorKey, contextKey), queryFn: ({ signal }: { signal: AbortSignal }) => - definition.fetchList({ ...queryArgs, signal }), + loadAllSelectorOptions(definition, { ...queryArgs, signal }), enabled: Boolean(selectorKey && baseEnabled), staleTime: definition.staleTime ?? 60 * 1000, select: (options: SelectorOption[]): WorkflowSearchReplacementOption[] => diff --git a/apps/sim/hooks/selectors/providers/confluence/selectors.ts b/apps/sim/hooks/selectors/providers/confluence/selectors.ts index 7ebf81640b..91cddd8cea 100644 --- a/apps/sim/hooks/selectors/providers/confluence/selectors.ts +++ b/apps/sim/hooks/selectors/providers/confluence/selectors.ts @@ -9,6 +9,13 @@ function formatConfluenceSpaceLabel(space: { name: string; key: string; status?: return space.status === 'archived' ? `${base} — archived` : base } +function toSpaceOption(space: { name: string; key: string; status?: string }): { + id: string + label: string +} { + return { id: space.key, label: formatConfluenceSpaceLabel(space) } +} + export const confluenceSelectors = { 'confluence.spaces': { key: 'confluence.spaces', @@ -21,28 +28,10 @@ export const confluenceSelectors = { context.domain ?? 'none', ], enabled: ({ context }) => Boolean(context.oauthCredential && context.domain), - fetchList: async ({ context, signal }: SelectorQueryArgs) => { - const credentialId = ensureCredential(context, 'confluence.spaces') - const domain = ensureDomain(context, 'confluence.spaces') - const collected: { id: string; label: string }[] = [] - let cursor: string | undefined - do { - const data = await requestJson(selectorContracts.confluenceSpacesSelectorContract, { - body: { - credential: credentialId, - workflowId: context.workflowId, - domain, - cursor, - }, - signal, - }) - for (const space of data.spaces || []) { - collected.push({ id: space.key, label: formatConfluenceSpaceLabel(space) }) - } - cursor = data.nextCursor - } while (cursor) - return collected - }, + /** + * Drives pagination through {@link useSelectorOptions}, which drains every + * page via this callback. No `fetchList` — the paged path supersedes it. + */ fetchPage: async ({ context, cursor, signal }) => { const credentialId = ensureCredential(context, 'confluence.spaces') const domain = ensureDomain(context, 'confluence.spaces') @@ -56,10 +45,7 @@ export const confluenceSelectors = { signal, }) return { - items: (data.spaces || []).map((space) => ({ - id: space.key, - label: formatConfluenceSpaceLabel(space), - })), + items: (data.spaces || []).map(toSpaceOption), nextCursor: data.nextCursor, } }, @@ -83,7 +69,7 @@ export const confluenceSelectors = { }) const space = (data.spaces || []).find((s) => s.key === detailId) ?? null if (!space) return null - return { id: space.key, label: formatConfluenceSpaceLabel(space) } + return toSpaceOption(space) }, }, 'confluence.pages': { diff --git a/apps/sim/hooks/selectors/providers/knowledge/selectors.ts b/apps/sim/hooks/selectors/providers/knowledge/selectors.ts index 01a76f44a1..c379d96c91 100644 --- a/apps/sim/hooks/selectors/providers/knowledge/selectors.ts +++ b/apps/sim/hooks/selectors/providers/knowledge/selectors.ts @@ -3,6 +3,8 @@ import * as selectorContracts from '@/lib/api/contracts/selectors' import { ensureKnowledgeBase, SELECTOR_STALE } from '@/hooks/selectors/providers/shared' import type { SelectorDefinition, SelectorKey, SelectorQueryArgs } from '@/hooks/selectors/types' +const KNOWLEDGE_DOCUMENTS_PAGE_LIMIT = 100 + export const knowledgeSelectors = { 'knowledge.documents': { key: 'knowledge.documents', @@ -18,20 +20,32 @@ export const knowledgeSelectors = { search ?? '', ], enabled: ({ context }) => Boolean(context.knowledgeBaseId), - fetchList: async ({ context, search, signal }: SelectorQueryArgs) => { + /** + * Drives pagination through {@link useSelectorOptions}, which drains every + * page via this callback. The `pagination.hasMore` flag from the route + * decides when to stop; `nextCursor` encodes the next `offset`. + */ + fetchPage: async ({ context, search, cursor, signal }) => { const knowledgeBaseId = ensureKnowledgeBase(context) + const offset = cursor ? Number(cursor) : 0 const result = await requestJson(selectorContracts.listKnowledgeSelectorDocumentsContract, { params: { id: knowledgeBaseId }, query: { - limit: 100, + limit: KNOWLEDGE_DOCUMENTS_PAGE_LIMIT, + offset, search, }, signal, }) - return result.data.documents.map((doc) => ({ - id: doc.id, - label: doc.filename, - })) + const { pagination } = result.data + const nextOffset = pagination.offset + pagination.limit + return { + items: result.data.documents.map((doc) => ({ + id: doc.id, + label: doc.filename, + })), + nextCursor: pagination.hasMore ? String(nextOffset) : undefined, + } }, fetchById: async ({ context, detailId, signal }: SelectorQueryArgs) => { if (!detailId) return null diff --git a/apps/sim/hooks/selectors/providers/microsoft/selectors.ts b/apps/sim/hooks/selectors/providers/microsoft/selectors.ts index e9b1dad6d2..f18a854cc4 100644 --- a/apps/sim/hooks/selectors/providers/microsoft/selectors.ts +++ b/apps/sim/hooks/selectors/providers/microsoft/selectors.ts @@ -320,6 +320,7 @@ export const microsoftSelectors = { query: search, driveId: context.driveId, workflowId: context.workflowId, + fileType: 'excel', }, signal, }) @@ -347,6 +348,7 @@ export const microsoftSelectors = { credentialId, query: search, workflowId: context.workflowId, + fileType: 'word', }, signal, }) diff --git a/apps/sim/hooks/selectors/registry.test.ts b/apps/sim/hooks/selectors/registry.test.ts index 8c287ea549..08f58b0572 100644 --- a/apps/sim/hooks/selectors/registry.test.ts +++ b/apps/sim/hooks/selectors/registry.test.ts @@ -61,7 +61,7 @@ describe('sim.workflows selector', () => { it('reads workflow options from the scoped workflow cache', async () => { const definition = getSelectorDefinition('sim.workflows') - const options = await definition.fetchList({ + const options = await definition.fetchList!({ key: 'sim.workflows', context: { workspaceId: 'ws-1', excludeWorkflowId: 'wf-2' }, }) @@ -112,7 +112,7 @@ describe('sim.workflows selector', () => { }) const definition = getSelectorDefinition('sim.workflows') - const options = await definition.fetchList({ + const options = await definition.fetchList!({ key: 'sim.workflows', context: { workspaceId: 'ws-1' }, }) diff --git a/apps/sim/hooks/selectors/registry.ts b/apps/sim/hooks/selectors/registry.ts index 26c9ac9494..e3af0f2b17 100644 --- a/apps/sim/hooks/selectors/registry.ts +++ b/apps/sim/hooks/selectors/registry.ts @@ -21,7 +21,12 @@ import { trelloSelectors } from '@/hooks/selectors/providers/trello/selectors' import { wealthboxSelectors } from '@/hooks/selectors/providers/wealthbox/selectors' import { webflowSelectors } from '@/hooks/selectors/providers/webflow/selectors' import { zoomSelectors } from '@/hooks/selectors/providers/zoom/selectors' -import type { SelectorDefinition, SelectorKey, SelectorOption } from '@/hooks/selectors/types' +import type { + SelectorDefinition, + SelectorKey, + SelectorOption, + SelectorQueryArgs, +} from '@/hooks/selectors/types' export const selectorRegistry = { ...airtableSelectors, @@ -57,6 +62,38 @@ export function getSelectorDefinition(key: SelectorKey): SelectorDefinition { return definition } +const MAX_LOAD_ALL_PAGES = 50 + +/** + * Loads the complete option list for a selector outside the React Query hook — + * for callers (search/replace, value resolution) that need every option in one + * call. Uses `fetchList` when defined, otherwise drains `fetchPage` (bounded by + * {@link MAX_LOAD_ALL_PAGES}). Returns an empty array for a selector that + * provides neither. + */ +export async function loadAllSelectorOptions( + definition: SelectorDefinition, + args: SelectorQueryArgs +): Promise { + if (definition.fetchList) { + return definition.fetchList(args) + } + + if (definition.fetchPage) { + const items: SelectorOption[] = [] + let cursor: string | undefined + for (let page = 0; page < MAX_LOAD_ALL_PAGES; page++) { + const { items: pageItems, nextCursor } = await definition.fetchPage({ ...args, cursor }) + items.push(...pageItems) + cursor = nextCursor + if (!cursor) break + } + return items + } + + return [] +} + export function mergeOption(options: SelectorOption[], option?: SelectorOption | null) { if (!option) return options if (options.some((item) => item.id === option.id)) { diff --git a/apps/sim/hooks/selectors/types.ts b/apps/sim/hooks/selectors/types.ts index ccf814fdc1..48af9cfda8 100644 --- a/apps/sim/hooks/selectors/types.ts +++ b/apps/sim/hooks/selectors/types.ts @@ -114,7 +114,12 @@ export interface SelectorDefinition { key: SelectorKey contracts?: readonly AnyApiRouteContract[] getQueryKey: (args: SelectorQueryArgs) => QueryKey - fetchList: (args: SelectorQueryArgs) => Promise + /** + * Loads the full option list in a single call. Required unless `fetchPage` is + * defined, in which case the hook drives pagination through `fetchPage` and + * `fetchList` is never invoked — provide one or the other, not both. + */ + fetchList?: (args: SelectorQueryArgs) => Promise /** * Optional. When defined, the selector hook fetches one page at a time and * auto-drains remaining pages so the dropdown populates progressively. diff --git a/apps/sim/hooks/selectors/use-selector-query.ts b/apps/sim/hooks/selectors/use-selector-query.ts index 8eb4755834..0bf7979d4c 100644 --- a/apps/sim/hooks/selectors/use-selector-query.ts +++ b/apps/sim/hooks/selectors/use-selector-query.ts @@ -1,4 +1,5 @@ import { useEffect, useMemo } from 'react' +import { createLogger } from '@sim/logger' import { useInfiniteQuery, useQuery } from '@tanstack/react-query' import { extractEnvVarName, isEnvVarReference, isReference } from '@/executor/constants' import { usePersonalEnvironment } from '@/hooks/queries/environment' @@ -30,11 +31,26 @@ export interface SelectorOptionsResult { * for non-paginated selectors. */ hasMore: boolean + /** + * True when the paginated drain stopped at {@link MAX_AUTO_DRAIN_PAGES} with + * pages still remaining, so the option list is a partial view. Always false + * for non-paginated selectors. + */ + truncated: boolean error: Error | null } +const logger = createLogger('SelectorQuery') + const EMPTY_PAGE: SelectorPage = { items: [], nextCursor: undefined } +/** + * Safety bound on the background auto-drain. Real dropdowns settle in a handful + * of pages; this only trips for pathological result sets and prevents an + * unbounded request loop when a provider keeps handing back cursors. + */ +const MAX_AUTO_DRAIN_PAGES = 50 + export function useSelectorOptions( key: SelectorKey, args: SelectorHookArgs @@ -50,7 +66,8 @@ export function useSelectorOptions( const flatQuery = useQuery({ queryKey: definition.getQueryKey(queryArgs), - queryFn: ({ signal }) => definition.fetchList({ ...queryArgs, signal }), + queryFn: ({ signal }) => + definition.fetchList?.({ ...queryArgs, signal }) ?? Promise.resolve([]), enabled: !supportsPagination && isEnabled, staleTime: definition.staleTime ?? 30_000, }) @@ -72,13 +89,33 @@ export function useSelectorOptions( }) const { hasNextPage, isFetchingNextPage, fetchNextPage, isError } = pagedQuery + const pageCount = pagedQuery.data?.pages.length ?? 0 + const reachedDrainCap = pageCount >= MAX_AUTO_DRAIN_PAGES useEffect(() => { if (!supportsPagination) return if (isError) return + if (reachedDrainCap) { + if (hasNextPage) { + logger.warn('Selector hit auto-drain cap; option list is truncated', { + key, + pages: pageCount, + }) + } + return + } if (hasNextPage && !isFetchingNextPage) { void fetchNextPage() } - }, [supportsPagination, hasNextPage, isFetchingNextPage, isError, fetchNextPage]) + }, [ + supportsPagination, + hasNextPage, + isFetchingNextPage, + isError, + fetchNextPage, + reachedDrainCap, + pageCount, + key, + ]) const pagedOptions = useMemo(() => { if (!supportsPagination) return undefined @@ -92,7 +129,8 @@ export function useSelectorOptions( isLoading: pagedQuery.isLoading, isFetching: pagedQuery.isFetching, isFetchingMore: pagedQuery.isFetchingNextPage, - hasMore: pagedQuery.hasNextPage ?? false, + hasMore: (pagedQuery.hasNextPage ?? false) && !reachedDrainCap, + truncated: reachedDrainCap && (pagedQuery.hasNextPage ?? false), error: (pagedQuery.error as Error | null) ?? null, } } @@ -103,6 +141,7 @@ export function useSelectorOptions( isFetching: flatQuery.isFetching, isFetchingMore: false, hasMore: false, + truncated: false, error: (flatQuery.error as Error | null) ?? null, } } diff --git a/apps/sim/lib/api/contracts/selectors/knowledge.ts b/apps/sim/lib/api/contracts/selectors/knowledge.ts index 6971a301db..1234c868f6 100644 --- a/apps/sim/lib/api/contracts/selectors/knowledge.ts +++ b/apps/sim/lib/api/contracts/selectors/knowledge.ts @@ -11,6 +11,7 @@ const knowledgeDocumentParamsSchema = knowledgeDocumentsParamsSchema.extend({ const knowledgeDocumentsQuerySchema = z.object({ limit: z.coerce.number().int().min(1).max(100).optional(), + offset: z.coerce.number().int().min(0).optional(), search: optionalString, }) diff --git a/apps/sim/lib/api/contracts/selectors/microsoft.ts b/apps/sim/lib/api/contracts/selectors/microsoft.ts index 513a48df98..0930489692 100644 --- a/apps/sim/lib/api/contracts/selectors/microsoft.ts +++ b/apps/sim/lib/api/contracts/selectors/microsoft.ts @@ -38,10 +38,17 @@ export const microsoftExcelDrivesBodySchema = credentialWorkflowBodySchema.exten driveId: optionalString, }) +/** + * The `/api/auth/oauth/microsoft/files` route is shared by the + * `microsoft.excel` and `microsoft.word` selectors. `fileType` lets the route + * search for and filter to the correct Office document type; it is optional and + * defaults to `excel` on the server for backward compatibility. + */ export const microsoftFilesQuerySchema = credentialIdQuerySchema.extend({ query: optionalString, driveId: optionalString, workflowId: optionalString, + fileType: z.enum(['excel', 'word']).optional(), }) export const microsoftFileQuerySchema = credentialIdQuerySchema.extend({ diff --git a/apps/sim/lib/oauth/google-pagination.ts b/apps/sim/lib/oauth/google-pagination.ts new file mode 100644 index 0000000000..c907288496 --- /dev/null +++ b/apps/sim/lib/oauth/google-pagination.ts @@ -0,0 +1,108 @@ +import { createLogger } from '@sim/logger' + +const logger = createLogger('GooglePagination') + +/** + * Thrown by {@link drainGooglePagedList} when a page request returns a non-OK + * HTTP status. Carries the status and parsed error body so callers can shape + * their existing error responses unchanged. + */ +export class GooglePageError extends Error { + readonly status: number + readonly body: unknown + + constructor(status: number, body: unknown) { + super(`Google API error: ${status}`) + this.name = 'GooglePageError' + this.status = status + this.body = body + } +} + +/** + * Result of draining a token-paginated Google REST list endpoint. + */ +export interface GoogleDrainResult { + /** All items accumulated across every fetched page. */ + items: T[] + /** True when the page cap was reached before the API stopped returning a token. */ + truncated: boolean +} + +/** + * Options for {@link drainGooglePagedList}. + */ +export interface DrainGooglePagedListOptions { + /** + * Builds the request URL for a given page. `pageToken` is `undefined` for the + * first page, then the value of the previous response's `nextPageToken`. + */ + buildUrl: (pageToken: string | undefined) => string + /** Performs the HTTP request for a built URL. */ + fetch: (url: string) => Promise + /** Parses an error body from a non-OK response (used to build {@link GooglePageError}). */ + parseError: (response: Response) => Promise + /** Extracts the array of items from a single page's JSON body. */ + getItems: (body: R) => T[] | undefined + /** Reads the continuation token from a single page's JSON body. */ + getNextPageToken: (body: R) => string | undefined + /** Maximum number of pages to fetch before stopping and flagging `truncated`. */ + maxPages: number + /** Label used in the cap-reached warning log. */ + label: string +} + +/** + * Drains a token-paginated Google REST list endpoint, following each response's + * `nextPageToken` until it is absent or the `maxPages` cap is hit. + * + * Mirrors the bounded-loop pattern used by the Slack channels selector: the loop + * is bounded and emits a `logger.warn` (and sets `truncated`) when the cap is + * reached rather than silently dropping items. A non-OK page response throws a + * {@link GooglePageError} carrying the status and parsed body so callers preserve + * their existing error-response shapes. + */ +export async function drainGooglePagedList( + options: DrainGooglePagedListOptions +): Promise> { + const { + buildUrl, + fetch: fetchPage, + parseError, + getItems, + getNextPageToken, + maxPages, + label, + } = options + + const items: T[] = [] + let pageToken: string | undefined + let truncated = false + + for (let page = 0; page < maxPages; page++) { + const response = await fetchPage(buildUrl(pageToken)) + + if (!response.ok) { + throw new GooglePageError(response.status, await parseError(response)) + } + + const body = (await response.json()) as R + + const pageItems = getItems(body) + if (pageItems?.length) { + items.push(...pageItems) + } + + pageToken = getNextPageToken(body)?.trim() || undefined + if (!pageToken) { + return { items, truncated } + } + + if (page === maxPages - 1) { + truncated = true + logger.warn(`${label}: hit pagination cap of ${maxPages} pages; results may be incomplete`) + } + } + + return { items, truncated } +} diff --git a/apps/sim/lib/workflows/comparison/resolve-values.ts b/apps/sim/lib/workflows/comparison/resolve-values.ts index 651a03ad50..bc9d6c7a75 100644 --- a/apps/sim/lib/workflows/comparison/resolve-values.ts +++ b/apps/sim/lib/workflows/comparison/resolve-values.ts @@ -6,7 +6,7 @@ import { SELECTOR_TYPES_HYDRATION_REQUIRED, type SubBlockConfig } from '@/blocks import { CREDENTIAL_SET, isUuid } from '@/executor/constants' import { fetchCredentialSetById } from '@/hooks/queries/credential-sets' import { fetchOAuthCredentialDetail } from '@/hooks/queries/oauth/oauth-credentials' -import { getSelectorDefinition } from '@/hooks/selectors/registry' +import { getSelectorDefinition, loadAllSelectorOptions } from '@/hooks/selectors/registry' import { resolveSelectorForSubBlock } from '@/hooks/selectors/resolution' import type { SelectorContext, SelectorKey } from '@/hooks/selectors/types' import type { WorkflowState } from '@/stores/workflows/workflow/types' @@ -107,7 +107,7 @@ async function resolveSelectorValue( } } - const options = await definition.fetchList({ + const options = await loadAllSelectorOptions(definition, { key: selectorKey, context: selectorContext, }) From e8f648547c6db07abfc8ffdf6dbfa7fa688c8115 Mon Sep 17 00:00:00 2001 From: Waleed Date: Sat, 30 May 2026 21:01:47 -0700 Subject: [PATCH 24/29] fix(sso): re-check domain conflict before write and reject IP-address domains (#4825) --- apps/sim/app/api/auth/sso/register/route.ts | 44 ++++++++++++++------- apps/sim/lib/auth/sso/domain.test.ts | 6 +++ apps/sim/lib/auth/sso/domain.ts | 5 ++- 3 files changed, 39 insertions(+), 16 deletions(-) diff --git a/apps/sim/app/api/auth/sso/register/route.ts b/apps/sim/app/api/auth/sso/register/route.ts index 235116fc9e..d564960704 100644 --- a/apps/sim/app/api/auth/sso/register/route.ts +++ b/apps/sim/app/api/auth/sso/register/route.ts @@ -81,28 +81,33 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return orgId ? provider.organizationId === orgId : false } - const existingProviders = await db - .select({ - userId: ssoProvider.userId, - organizationId: ssoProvider.organizationId, - }) - .from(ssoProvider) - .where(sql`lower(${ssoProvider.domain}) = ${domain}`) - const conflictingProvider = existingProviders.find((provider) => !isOwnedByCaller(provider)) + const findDomainConflict = async () => + ( + await db + .select({ + userId: ssoProvider.userId, + organizationId: ssoProvider.organizationId, + }) + .from(ssoProvider) + .where(sql`lower(${ssoProvider.domain}) = ${domain}`) + ).find((provider) => !isOwnedByCaller(provider)) - if (conflictingProvider) { - logger.warn('Rejected SSO registration for domain owned by another tenant', { - domain, - orgId, - userId: session.user.id, - }) - return NextResponse.json( + const domainConflictResponse = () => + NextResponse.json( { error: 'This domain is already registered for SSO by another organization.', code: 'SSO_DOMAIN_ALREADY_REGISTERED', }, { status: 409 } ) + + if (await findDomainConflict()) { + logger.warn('Rejected SSO registration for domain owned by another tenant', { + domain, + orgId, + userId: session.user.id, + }) + return domainConflictResponse() } const headers: Record = {} @@ -446,6 +451,15 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ), }) + if (await findDomainConflict()) { + logger.warn('Rejected SSO registration: domain was claimed during registration', { + domain, + orgId, + userId: session.user.id, + }) + return domainConflictResponse() + } + const registration = await auth.api.registerSSOProvider({ body: providerConfig, headers, diff --git a/apps/sim/lib/auth/sso/domain.test.ts b/apps/sim/lib/auth/sso/domain.test.ts index 5ca62331a8..333a6576b5 100644 --- a/apps/sim/lib/auth/sso/domain.test.ts +++ b/apps/sim/lib/auth/sso/domain.test.ts @@ -35,4 +35,10 @@ describe('normalizeSSODomain', () => { expect(normalizeSSODomain('not a domain')).toBeNull() expect(normalizeSSODomain('company')).toBeNull() }) + + it('rejects bare IP addresses and numeric TLDs', () => { + expect(normalizeSSODomain('10.0.0.1')).toBeNull() + expect(normalizeSSODomain('192.168.1.1')).toBeNull() + expect(normalizeSSODomain('company.123')).toBeNull() + }) }) diff --git a/apps/sim/lib/auth/sso/domain.ts b/apps/sim/lib/auth/sso/domain.ts index bdd0cc1714..30f6470b15 100644 --- a/apps/sim/lib/auth/sso/domain.ts +++ b/apps/sim/lib/auth/sso/domain.ts @@ -19,7 +19,10 @@ export function normalizeSSODomain(input: string): string | null { value = value.replace(/\.$/, '') if (!/^[a-z0-9-]+(\.[a-z0-9-]+)+$/.test(value)) return null - if (value.split('.').some((label) => label.length === 0 || label.length > 63)) return null + + const labels = value.split('.') + if (labels.some((label) => label.length === 0 || label.length > 63)) return null + if (/^\d+$/.test(labels[labels.length - 1])) return null return value } From b399ee0fe27b8f9cacdb9f9ad77d1ee2a3b73692 Mon Sep 17 00:00:00 2001 From: Waleed Date: Sun, 31 May 2026 11:22:02 -0700 Subject: [PATCH 25/29] improvement(copilot): make copilot_messages the sole transcript store, remove JSONB dual-write (#4826) Stop writing/reading the legacy copilot_chats.messages JSONB column now that reads are cut over to copilot_messages. Make appendCopilotChatMessages the primary write (throws on failure instead of swallowing), repoint peripheral readers (workspace VFS, chat cleanup, data drains, fork, superuser import) to copilot_messages, and persist the assistant turn inside finalizeAssistantTurn's transaction so it commits atomically with the stream-marker clear. The column itself is dropped in a follow-up migration after this bakes. --- .../app/api/copilot/chat/stop/route.test.ts | 196 ++++--------- .../chat/update-messages/route.test.ts | 49 +++- .../api/copilot/chat/update-messages/route.ts | 28 +- .../mothership/chats/[chatId]/fork/route.ts | 67 +++-- apps/sim/app/api/mothership/chats/route.ts | 1 - .../api/superuser/import-workflow/route.ts | 60 ++-- apps/sim/lib/cleanup/chat-cleanup.ts | 49 ++-- apps/sim/lib/copilot/chat/lifecycle.test.ts | 6 +- apps/sim/lib/copilot/chat/lifecycle.ts | 20 +- .../lib/copilot/chat/messages-dual-write.ts | 143 ---------- ...l-write.test.ts => messages-store.test.ts} | 14 +- apps/sim/lib/copilot/chat/messages-store.ts | 122 ++++++++ apps/sim/lib/copilot/chat/post.test.ts | 40 ++- apps/sim/lib/copilot/chat/post.ts | 66 ++--- .../lib/copilot/chat/terminal-state.test.ts | 263 +++++++----------- apps/sim/lib/copilot/chat/terminal-state.ts | 62 ++--- apps/sim/lib/copilot/vfs/workspace-vfs.ts | 21 +- .../lib/data-drains/sources/copilot-chats.ts | 67 ++++- apps/sim/lib/mothership/inbox/executor.ts | 36 +-- 19 files changed, 612 insertions(+), 698 deletions(-) delete mode 100644 apps/sim/lib/copilot/chat/messages-dual-write.ts rename apps/sim/lib/copilot/chat/{messages-dual-write.test.ts => messages-store.test.ts} (95%) create mode 100644 apps/sim/lib/copilot/chat/messages-store.ts diff --git a/apps/sim/app/api/copilot/chat/stop/route.test.ts b/apps/sim/app/api/copilot/chat/stop/route.test.ts index bab5465507..452131f21e 100644 --- a/apps/sim/app/api/copilot/chat/stop/route.test.ts +++ b/apps/sim/app/api/copilot/chat/stop/route.test.ts @@ -1,79 +1,19 @@ /** * @vitest-environment node */ -import { authMockFns } from '@sim/testing' +import { authMockFns, dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing' import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { - mockSelect, - mockFrom, - mockWhereSelect, - mockLimit, - mockForUpdate, - mockUpdate, - mockSet, - mockWhereUpdate, - mockReturning, - mockPublishStatusChanged, - mockSql, - mockTransaction, -} = vi.hoisted(() => { - const mockSelect = vi.fn() - const mockFrom = vi.fn() - const mockWhereSelect = vi.fn() - const mockLimit = vi.fn() - const mockForUpdate = vi.fn() - const mockUpdate = vi.fn() - const mockSet = vi.fn() - const mockWhereUpdate = vi.fn() - const mockReturning = vi.fn() - const mockPublishStatusChanged = vi.fn() - const mockSql = vi.fn((strings: TemplateStringsArray, ...values: unknown[]) => ({ - strings, - values, - })) - const mockTransaction = vi.fn( - (callback: (tx: { select: typeof mockSelect; update: typeof mockUpdate }) => unknown) => - callback({ select: mockSelect, update: mockUpdate }) - ) +vi.mock('@sim/db', () => dbChainMock) - return { - mockSelect, - mockFrom, - mockWhereSelect, - mockLimit, - mockForUpdate, - mockUpdate, - mockSet, - mockWhereUpdate, - mockReturning, - mockPublishStatusChanged, - mockSql, - mockTransaction, - } -}) - -vi.mock('@sim/db/schema', () => ({ - copilotChats: { - id: 'copilotChats.id', - userId: 'copilotChats.userId', - workspaceId: 'copilotChats.workspaceId', - messages: 'copilotChats.messages', - conversationId: 'copilotChats.conversationId', - }, +const { mockAppendCopilotChatMessages, mockPublishStatusChanged } = vi.hoisted(() => ({ + mockAppendCopilotChatMessages: vi.fn(), + mockPublishStatusChanged: vi.fn(), })) -vi.mock('@sim/db', () => ({ - db: { - transaction: mockTransaction, - }, -})) - -vi.mock('drizzle-orm', () => ({ - and: vi.fn((...conditions: unknown[]) => ({ conditions, type: 'and' })), - eq: vi.fn((field: unknown, value: unknown) => ({ field, value, type: 'eq' })), - sql: mockSql, +vi.mock('@/lib/copilot/chat/messages-store', () => ({ + appendCopilotChatMessages: mockAppendCopilotChatMessages, })) vi.mock('@/lib/copilot/tasks', () => ({ @@ -92,39 +32,33 @@ function createRequest(body: Record) { }) } +/** + * Sequence the two in-tx reads `finalizeAssistantTurn` issues: the chat row + * (`FOR UPDATE ... LIMIT 1`) and the last-message lookup that drives dedup + * (both terminate on `.limit(1)`). + */ +function mockReads(opts: { + chat: Record | null + last?: { messageId: string; role: string } +}) { + dbChainMockFns.limit.mockResolvedValueOnce(opts.chat ? [opts.chat] : []) + dbChainMockFns.limit.mockResolvedValueOnce(opts.last ? [opts.last] : []) +} + describe('copilot chat stop route', () => { beforeEach(() => { vi.clearAllMocks() - + // Drain the once-queue (clearAllMocks/resetDbChainMock don't), then restore defaults. + dbChainMockFns.limit.mockReset() + resetDbChainMock() authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-1' } }) - - mockLimit.mockResolvedValue([ - { - workspaceId: 'ws-1', - messages: [{ id: 'stream-1', role: 'user', content: 'hello' }], - conversationId: 'stream-1', - }, - ]) - mockForUpdate.mockReturnValue({ limit: mockLimit }) - mockWhereSelect.mockReturnValue({ for: mockForUpdate }) - mockFrom.mockReturnValue({ where: mockWhereSelect }) - mockSelect.mockReturnValue({ from: mockFrom }) - - mockReturning.mockResolvedValue([{ workspaceId: 'ws-1' }]) - mockWhereUpdate.mockReturnValue({ returning: mockReturning }) - mockSet.mockReturnValue({ where: mockWhereUpdate }) - mockUpdate.mockReturnValue({ set: mockSet }) }) it('returns 401 when unauthenticated', async () => { authMockFns.mockGetSession.mockResolvedValueOnce(null) const response = await POST( - createRequest({ - chatId: 'chat-1', - streamId: 'stream-1', - content: '', - }) + createRequest({ chatId: 'chat-1', streamId: 'stream-1', content: '' }) ) expect(response.status).toBe(401) @@ -132,41 +66,37 @@ describe('copilot chat stop route', () => { }) it('is a no-op when the chat is missing', async () => { - mockLimit.mockResolvedValueOnce([]) + mockReads({ chat: null }) const response = await POST( - createRequest({ - chatId: 'missing-chat', - streamId: 'stream-1', - content: '', - }) + createRequest({ chatId: 'missing-chat', streamId: 'stream-1', content: '' }) ) expect(response.status).toBe(200) expect(await response.json()).toEqual({ success: true }) - expect(mockUpdate).not.toHaveBeenCalled() + expect(mockAppendCopilotChatMessages).not.toHaveBeenCalled() }) it('appends a stopped assistant message even with no content', async () => { + mockReads({ + chat: { workspaceId: 'ws-1', conversationId: 'stream-1', model: null }, + last: { messageId: 'stream-1', role: 'user' }, + }) + const response = await POST( - createRequest({ - chatId: 'chat-1', - streamId: 'stream-1', - content: '', - }) + createRequest({ chatId: 'chat-1', streamId: 'stream-1', content: '' }) ) expect(response.status).toBe(200) expect(await response.json()).toEqual({ success: true }) - const setArg = mockSet.mock.calls[0]?.[0] - expect(setArg).toBeTruthy() + const setArg = dbChainMockFns.set.mock.calls[0]?.[0] as Record expect(setArg.conversationId).toBeNull() - expect(setArg.messages).toBeTruthy() + expect(Object.hasOwn(setArg, 'messages')).toBe(false) - const appendedPayload = JSON.parse(setArg.messages.values[1] as string) - expect(appendedPayload).toHaveLength(1) - expect(appendedPayload[0]).toMatchObject({ + expect(mockAppendCopilotChatMessages).toHaveBeenCalledTimes(1) + const [, appended] = mockAppendCopilotChatMessages.mock.calls[0] + expect(appended[0]).toMatchObject({ role: 'assistant', content: '', contentBlocks: [{ type: 'complete', status: 'cancelled' }], @@ -181,32 +111,21 @@ describe('copilot chat stop route', () => { }) it('appends a stopped assistant message if the stream marker was already cleared', async () => { - mockLimit.mockResolvedValueOnce([ - { - workspaceId: 'ws-1', - messages: [{ id: 'stream-1', role: 'user', content: 'hello' }], - conversationId: null, - }, - ]) + mockReads({ + chat: { workspaceId: 'ws-1', conversationId: null, model: null }, + last: { messageId: 'stream-1', role: 'user' }, + }) const response = await POST( - createRequest({ - chatId: 'chat-1', - streamId: 'stream-1', - content: 'partial', - }) + createRequest({ chatId: 'chat-1', streamId: 'stream-1', content: 'partial' }) ) expect(response.status).toBe(200) expect(await response.json()).toEqual({ success: true }) - const setArg = mockSet.mock.calls[0]?.[0] - expect(setArg.messages).toBeTruthy() - const appendedPayload = JSON.parse(setArg.messages.values[1] as string) - expect(appendedPayload[0]).toMatchObject({ - role: 'assistant', - content: 'partial', - }) + expect(mockAppendCopilotChatMessages).toHaveBeenCalledTimes(1) + const [, appended] = mockAppendCopilotChatMessages.mock.calls[0] + expect(appended[0]).toMatchObject({ role: 'assistant', content: 'partial' }) expect(mockPublishStatusChanged).toHaveBeenCalledWith({ workspaceId: 'ws-1', @@ -217,28 +136,19 @@ describe('copilot chat stop route', () => { }) it('republishes completed status when the assistant was already persisted', async () => { - mockLimit.mockResolvedValueOnce([ - { - workspaceId: 'ws-1', - messages: [ - { id: 'stream-1', role: 'user', content: 'hello' }, - { id: 'assistant-1', role: 'assistant', content: 'partial' }, - ], - conversationId: null, - }, - ]) + mockReads({ + chat: { workspaceId: 'ws-1', conversationId: null, model: null }, + last: { messageId: 'assistant-1', role: 'assistant' }, + }) const response = await POST( - createRequest({ - chatId: 'chat-1', - streamId: 'stream-1', - content: 'partial', - }) + createRequest({ chatId: 'chat-1', streamId: 'stream-1', content: 'partial' }) ) expect(response.status).toBe(200) expect(await response.json()).toEqual({ success: true }) - expect(mockUpdate).not.toHaveBeenCalled() + expect(mockAppendCopilotChatMessages).not.toHaveBeenCalled() + expect(dbChainMockFns.set).not.toHaveBeenCalled() expect(mockPublishStatusChanged).toHaveBeenCalledWith({ workspaceId: 'ws-1', chatId: 'chat-1', diff --git a/apps/sim/app/api/copilot/chat/update-messages/route.test.ts b/apps/sim/app/api/copilot/chat/update-messages/route.test.ts index c56415116a..2e7dfa134c 100644 --- a/apps/sim/app/api/copilot/chat/update-messages/route.test.ts +++ b/apps/sim/app/api/copilot/chat/update-messages/route.test.ts @@ -16,6 +16,7 @@ const { mockSet, mockUpdateWhere, mockReturning, + mockReplaceCopilotChatMessages, } = vi.hoisted(() => ({ mockSelect: vi.fn(), mockFrom: vi.fn(), @@ -25,15 +26,23 @@ const { mockSet: vi.fn(), mockUpdateWhere: vi.fn(), mockReturning: vi.fn(), + mockReplaceCopilotChatMessages: vi.fn(), })) vi.mock('@sim/db', () => ({ db: { select: mockSelect, update: mockUpdate, + transaction: async ( + cb: (tx: { update: typeof mockUpdate; select: typeof mockSelect }) => unknown + ) => cb({ update: mockUpdate, select: mockSelect }), }, })) +vi.mock('@/lib/copilot/chat/messages-store', () => ({ + replaceCopilotChatMessages: mockReplaceCopilotChatMessages, +})) + vi.mock('drizzle-orm', () => ({ and: vi.fn((...conditions: unknown[]) => ({ conditions, type: 'and' })), eq: vi.fn((field: unknown, value: unknown) => ({ field, value, type: 'eq' })), @@ -257,10 +266,13 @@ describe('Copilot Chat Update Messages API Route', () => { expect(mockSelect).toHaveBeenCalled() expect(mockUpdate).toHaveBeenCalled() - expect(mockSet).toHaveBeenCalledWith({ + expect(mockSet).toHaveBeenCalledWith({ updatedAt: expect.any(Date) }) + expect(mockReplaceCopilotChatMessages).toHaveBeenCalledWith( + 'chat-123', messages, - updatedAt: expect.any(Date), - }) + { chatModel: 'gpt-4' }, + expect.anything() + ) }) it('should successfully update chat messages with optional fields', async () => { @@ -315,8 +327,10 @@ describe('Copilot Chat Update Messages API Route', () => { messageCount: 2, }) - expect(mockSet).toHaveBeenCalledWith({ - messages: [ + expect(mockSet).toHaveBeenCalledWith({ updatedAt: expect.any(Date) }) + expect(mockReplaceCopilotChatMessages).toHaveBeenCalledWith( + 'chat-456', + [ { id: 'msg-1', role: 'user', @@ -345,8 +359,9 @@ describe('Copilot Chat Update Messages API Route', () => { ], }, ], - updatedAt: expect.any(Date), - }) + { chatModel: 'gpt-4' }, + expect.anything() + ) }) it('should handle empty messages array', async () => { @@ -373,10 +388,13 @@ describe('Copilot Chat Update Messages API Route', () => { messageCount: 0, }) - expect(mockSet).toHaveBeenCalledWith({ - messages: [], - updatedAt: expect.any(Date), - }) + expect(mockSet).toHaveBeenCalledWith({ updatedAt: expect.any(Date) }) + expect(mockReplaceCopilotChatMessages).toHaveBeenCalledWith( + 'chat-789', + [], + { chatModel: 'gpt-4' }, + expect.anything() + ) }) it('should handle database errors during chat lookup', async () => { @@ -485,10 +503,13 @@ describe('Copilot Chat Update Messages API Route', () => { messageCount: 100, }) - expect(mockSet).toHaveBeenCalledWith({ + expect(mockSet).toHaveBeenCalledWith({ updatedAt: expect.any(Date) }) + expect(mockReplaceCopilotChatMessages).toHaveBeenCalledWith( + 'chat-large', messages, - updatedAt: expect.any(Date), - }) + { chatModel: 'gpt-4' }, + expect.anything() + ) }) it('should handle messages with both user and assistant roles', async () => { diff --git a/apps/sim/app/api/copilot/chat/update-messages/route.ts b/apps/sim/app/api/copilot/chat/update-messages/route.ts index 1b654c4930..7c7792e3f2 100644 --- a/apps/sim/app/api/copilot/chat/update-messages/route.ts +++ b/apps/sim/app/api/copilot/chat/update-messages/route.ts @@ -6,7 +6,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { updateCopilotMessagesContract } from '@/lib/api/contracts/copilot' import { parseRequest } from '@/lib/api/server' import { getAccessibleCopilotChatAuth } from '@/lib/copilot/chat/lifecycle' -import { replaceCopilotChatMessages } from '@/lib/copilot/chat/messages-dual-write' +import { replaceCopilotChatMessages } from '@/lib/copilot/chat/messages-store' import { normalizeMessage, type PersistedMessage } from '@/lib/copilot/chat/persisted-message' import { authenticateCopilotRequestSessionOnly, @@ -73,9 +73,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => { return createNotFoundResponse('Chat not found or unauthorized') } - // Update chat with new messages, plan artifact, and config const updateData: Record = { - messages: normalizedMessages, updatedAt: new Date(), } @@ -87,16 +85,20 @@ export const POST = withRouteHandler(async (req: NextRequest) => { updateData.config = config } - const [updated] = await db - .update(copilotChats) - .set(updateData) - .where(eq(copilotChats.id, chatId)) - .returning({ model: copilotChats.model }) - if (updated) { - await replaceCopilotChatMessages(chatId, normalizedMessages, { - chatModel: updated.model ?? null, - }) - } + await db.transaction(async (tx) => { + const [updated] = await tx + .update(copilotChats) + .set(updateData) + .where(eq(copilotChats.id, chatId)) + .returning({ model: copilotChats.model }) + if (!updated) return + await replaceCopilotChatMessages( + chatId, + normalizedMessages, + { chatModel: updated.model ?? null }, + tx + ) + }) logger.info(`[${tracker.requestId}] Successfully updated chat`, { chatId, diff --git a/apps/sim/app/api/mothership/chats/[chatId]/fork/route.ts b/apps/sim/app/api/mothership/chats/[chatId]/fork/route.ts index 54f4cc588e..fec6bc6c19 100644 --- a/apps/sim/app/api/mothership/chats/[chatId]/fork/route.ts +++ b/apps/sim/app/api/mothership/chats/[chatId]/fork/route.ts @@ -6,8 +6,8 @@ import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { forkMothershipChatContract } from '@/lib/api/contracts/mothership-tasks' import { parseRequest } from '@/lib/api/server' -import { appendCopilotChatMessages } from '@/lib/copilot/chat/messages-dual-write' -import type { PersistedMessage } from '@/lib/copilot/chat/persisted-message' +import { loadCopilotChatMessages } from '@/lib/copilot/chat/lifecycle' +import { appendCopilotChatMessages } from '@/lib/copilot/chat/messages-store' import { fetchGo } from '@/lib/copilot/request/go/fetch' import { authenticateCopilotRequestSessionOnly, @@ -50,9 +50,19 @@ export const POST = withRouteHandler( const { chatId } = parsed.data.params const { upToMessageId } = parsed.data.body - // Load parent chat and verify ownership. const [parent] = await db - .select() + .select({ + id: copilotChats.id, + userId: copilotChats.userId, + type: copilotChats.type, + workspaceId: copilotChats.workspaceId, + title: copilotChats.title, + model: copilotChats.model, + resources: copilotChats.resources, + previewYaml: copilotChats.previewYaml, + planArtifact: copilotChats.planArtifact, + config: copilotChats.config, + }) .from(copilotChats) .where(eq(copilotChats.id, chatId)) .limit(1) @@ -65,8 +75,7 @@ export const POST = withRouteHandler( await assertActiveWorkspaceAccess(parent.workspaceId, userId) } - // Find the fork point in the Sim-side messages array. - const messages = Array.isArray(parent.messages) ? (parent.messages as PersistedMessage[]) : [] + const messages = await loadCopilotChatMessages(chatId) const forkIdx = messages.findIndex((m) => m.id === upToMessageId) if (forkIdx < 0) { return createBadRequestResponse('Message not found in chat') @@ -83,32 +92,36 @@ export const POST = withRouteHandler( const title = `Fork | ${baseTitle}` const now = new Date() - const [newChat] = await db - .insert(copilotChats) - .values({ - id: newId, - userId, - workspaceId: parent.workspaceId, - type: parent.type, - title, - model: parent.model, - messages: forkedMessages, - resources: parentResources, - previewYaml: parent.previewYaml, - planArtifact: parent.planArtifact, - config: parent.config, - conversationId: null, - updatedAt: now, - lastSeenAt: now, - }) - .returning({ id: copilotChats.id, workspaceId: copilotChats.workspaceId }) + const newChat = await db.transaction(async (tx) => { + const [row] = await tx + .insert(copilotChats) + .values({ + id: newId, + userId, + workspaceId: parent.workspaceId, + type: parent.type, + title, + model: parent.model, + resources: parentResources, + previewYaml: parent.previewYaml, + planArtifact: parent.planArtifact, + config: parent.config, + conversationId: null, + updatedAt: now, + lastSeenAt: now, + }) + .returning({ id: copilotChats.id, workspaceId: copilotChats.workspaceId }) + + if (!row) return null + + await appendCopilotChatMessages(newId, forkedMessages, { chatModel: parent.model }, tx) + return row + }) if (!newChat) { return createInternalServerErrorResponse('Failed to create forked chat') } - await appendCopilotChatMessages(newId, forkedMessages, { chatModel: parent.model }) - // Clone copilot-service conversation state (messages, active_messages, memory files). // Best-effort: if the copilot service doesn't have a row for the source chat yet, skip. try { diff --git a/apps/sim/app/api/mothership/chats/route.ts b/apps/sim/app/api/mothership/chats/route.ts index 1b7157fdde..c5610da215 100644 --- a/apps/sim/app/api/mothership/chats/route.ts +++ b/apps/sim/app/api/mothership/chats/route.ts @@ -106,7 +106,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { type: 'mothership', title: null, model: 'claude-opus-4-6', - messages: [], updatedAt: now, lastSeenAt: now, }) diff --git a/apps/sim/app/api/superuser/import-workflow/route.ts b/apps/sim/app/api/superuser/import-workflow/route.ts index 912278040b..07ab98932b 100644 --- a/apps/sim/app/api/superuser/import-workflow/route.ts +++ b/apps/sim/app/api/superuser/import-workflow/route.ts @@ -7,8 +7,8 @@ import { type NextRequest, NextResponse } from 'next/server' import { importWorkflowAsSuperuserContract } from '@/lib/api/contracts/workflows' import { parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' -import { appendCopilotChatMessages } from '@/lib/copilot/chat/messages-dual-write' -import type { PersistedMessage } from '@/lib/copilot/chat/persisted-message' +import { loadCopilotChatMessages } from '@/lib/copilot/chat/lifecycle' +import { appendCopilotChatMessages } from '@/lib/copilot/chat/messages-store' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { verifyEffectiveSuperUser } from '@/lib/templates/permissions' import { parseWorkflowJson } from '@/lib/workflows/operations/import-export' @@ -167,34 +167,46 @@ export const POST = withRouteHandler(async (request: NextRequest) => { // Copy copilot chats associated with the source workflow const sourceCopilotChats = await db - .select() + .select({ + id: copilotChats.id, + title: copilotChats.title, + model: copilotChats.model, + previewYaml: copilotChats.previewYaml, + planArtifact: copilotChats.planArtifact, + config: copilotChats.config, + }) .from(copilotChats) .where(eq(copilotChats.workflowId, workflowId)) let copilotChatsImported = 0 for (const chat of sourceCopilotChats) { - const [imported] = await db - .insert(copilotChats) - .values({ - userId: session.user.id, - workflowId: newWorkflowId, - title: chat.title ? `[Import] ${chat.title}` : null, - messages: chat.messages, - model: chat.model, - conversationId: null, // Don't copy conversation ID - previewYaml: chat.previewYaml, - planArtifact: chat.planArtifact, - config: chat.config, - createdAt: new Date(), - updatedAt: new Date(), - }) - .returning({ id: copilotChats.id }) - if (imported && Array.isArray(chat.messages) && chat.messages.length > 0) { - await appendCopilotChatMessages(imported.id, chat.messages as PersistedMessage[], { - chatModel: chat.model, - }) - } + const sourceMessages = await loadCopilotChatMessages(chat.id) + await db.transaction(async (tx) => { + const [imported] = await tx + .insert(copilotChats) + .values({ + userId: session.user.id, + workflowId: newWorkflowId, + title: chat.title ? `[Import] ${chat.title}` : null, + model: chat.model, + conversationId: null, // Don't copy conversation ID + previewYaml: chat.previewYaml, + planArtifact: chat.planArtifact, + config: chat.config, + createdAt: new Date(), + updatedAt: new Date(), + }) + .returning({ id: copilotChats.id }) + if (imported && sourceMessages.length > 0) { + await appendCopilotChatMessages( + imported.id, + sourceMessages, + { chatModel: chat.model }, + tx + ) + } + }) copilotChatsImported++ } diff --git a/apps/sim/lib/cleanup/chat-cleanup.ts b/apps/sim/lib/cleanup/chat-cleanup.ts index b154687bc3..c5dafdf9c2 100644 --- a/apps/sim/lib/cleanup/chat-cleanup.ts +++ b/apps/sim/lib/cleanup/chat-cleanup.ts @@ -1,5 +1,5 @@ import { db } from '@sim/db' -import { copilotChats, workspaceFiles } from '@sim/db/schema' +import { copilotMessages, workspaceFiles } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, inArray, isNull } from 'drizzle-orm' import { chunkArray } from '@/lib/cleanup/batch-delete' @@ -11,7 +11,7 @@ import { isUsingCloudStorage, StorageService } from '@/lib/uploads' const logger = createLogger('ChatCleanup') const COPILOT_CLEANUP_BATCH_SIZE = 1000 -/** Bounds JSONB detoast memory: `messages` can be MBs per row. */ +/** Bounds how many chats' `copilot_messages` rows are scanned per query. */ const CHAT_FILE_COLLECT_CHUNK_SIZE = 500 /** @@ -31,7 +31,7 @@ interface FileRef { /** * Collect all file storage keys for the given chat IDs from two sources: * 1. workspaceFiles rows with chatId FK (chat-scoped contexts only) - * 2. fileAttachments[].key inside copilotChats.messages JSONB + * 2. fileAttachments[].key inside each copilot_messages.content */ export async function collectChatFiles(chatIds: string[]): Promise { const files: FileRef[] = [] @@ -40,7 +40,7 @@ export async function collectChatFiles(chatIds: string[]): Promise { const seen = new Set() for (const chunk of chunkArray(chatIds, CHAT_FILE_COLLECT_CHUNK_SIZE)) { - const [linkedFiles, chatsWithMessages] = await Promise.all([ + const [linkedFiles, messageRows] = await Promise.all([ db .select({ key: workspaceFiles.key, context: workspaceFiles.context }) .from(workspaceFiles) @@ -51,10 +51,12 @@ export async function collectChatFiles(chatIds: string[]): Promise { inArray(workspaceFiles.context, [...CHAT_SCOPED_CONTEXTS]) ) ), + // Scan every message row for the chat (no deleted_at filter): this is a + // deletion path collecting blob keys, so attachments on any row count. db - .select({ messages: copilotChats.messages }) - .from(copilotChats) - .where(inArray(copilotChats.id, chunk)), + .select({ content: copilotMessages.content }) + .from(copilotMessages) + .where(inArray(copilotMessages.chatId, chunk)), ]) for (const f of linkedFiles) { @@ -64,24 +66,21 @@ export async function collectChatFiles(chatIds: string[]): Promise { } } - for (const chat of chatsWithMessages) { - const messages = chat.messages as unknown[] - if (!Array.isArray(messages)) continue - for (const msg of messages) { - if (!msg || typeof msg !== 'object') continue - const attachments = (msg as Record).fileAttachments - if (!Array.isArray(attachments)) continue - for (const attachment of attachments) { - if ( - attachment && - typeof attachment === 'object' && - (attachment as Record).key - ) { - const key = (attachment as Record).key as string - if (!seen.has(key)) { - seen.add(key) - files.push({ key, context: 'copilot' }) - } + for (const row of messageRows) { + const msg = row.content + if (!msg || typeof msg !== 'object') continue + const attachments = (msg as Record).fileAttachments + if (!Array.isArray(attachments)) continue + for (const attachment of attachments) { + if ( + attachment && + typeof attachment === 'object' && + (attachment as Record).key + ) { + const key = (attachment as Record).key as string + if (!seen.has(key)) { + seen.add(key) + files.push({ key, context: 'copilot' }) } } } diff --git a/apps/sim/lib/copilot/chat/lifecycle.test.ts b/apps/sim/lib/copilot/chat/lifecycle.test.ts index df1689cc68..3ced9edfae 100644 --- a/apps/sim/lib/copilot/chat/lifecycle.test.ts +++ b/apps/sim/lib/copilot/chat/lifecycle.test.ts @@ -122,13 +122,15 @@ describe('lifecycle copilot chat reads (cutover to copilot_messages)', () => { }) it('resolveOrCreateChat creates a new chat with an empty transcript', async () => { - // insert().values().returning() -> fresh chat with empty messages - dbChainMockFns.returning.mockResolvedValueOnce([{ ...chatRow, messages: [] }]) + dbChainMockFns.returning.mockResolvedValueOnce([chatRow]) const result = await resolveOrCreateChat({ userId: USER_ID, model: 'm' }) expect(result.isNew).toBe(true) expect(result.conversationHistory).toEqual([]) + expect(result.chat?.messages).toEqual([]) + const insertValues = dbChainMockFns.values.mock.calls[0]?.[0] as Record + expect(Object.hasOwn(insertValues, 'messages')).toBe(false) // a brand-new chat must not trigger a messages read expect(dbChainMockFns.orderBy).not.toHaveBeenCalled() }) diff --git a/apps/sim/lib/copilot/chat/lifecycle.ts b/apps/sim/lib/copilot/chat/lifecycle.ts index e86c127c10..1fcb5e51af 100644 --- a/apps/sim/lib/copilot/chat/lifecycle.ts +++ b/apps/sim/lib/copilot/chat/lifecycle.ts @@ -6,6 +6,7 @@ import { getActiveWorkflowRecord, } from '@sim/workflow-authz' import { and, asc, eq, isNull, sql } from 'drizzle-orm' +import type { PersistedMessage } from '@/lib/copilot/chat/persisted-message' import { assertActiveWorkspaceAccess, checkWorkspaceAccess, @@ -52,16 +53,6 @@ const copilotChatDetailColumns = { updatedAt: copilotChats.updatedAt, } as const -/** - * Returning column set for newly-inserted chats. A fresh chat has no - * `copilot_messages` rows yet, so the transcript is the just-inserted empty - * JSONB array — return it directly rather than issuing a second query. - */ -const copilotChatDetailReturningColumns = { - ...copilotChatDetailColumns, - messages: copilotChats.messages, -} as const - /** * Column set for the legacy copilot chat detail endpoint. Extends * `copilotChatDetailColumns` with `model`, `planArtifact`, and `config` — the @@ -83,7 +74,7 @@ const copilotChatLegacyDetailColumns = { * to a legacy JSONB array element — so the downstream normalize/transcript * pipeline is unchanged. */ -async function loadCopilotChatMessages(chatId: string): Promise[]> { +export async function loadCopilotChatMessages(chatId: string): Promise { const rows = await db .select({ content: copilotMessages.content }) .from(copilotMessages) @@ -93,7 +84,7 @@ async function loadCopilotChatMessages(chatId: string): Promise row.content as Record) + return rows.map((row) => row.content as PersistedMessage) } type CopilotChatAuthRow = Pick< @@ -298,10 +289,9 @@ export async function resolveOrCreateChat(params: { type: type ?? 'copilot', title: null, model, - messages: [], lastSeenAt: now, }) - .returning(copilotChatDetailReturningColumns) + .returning(copilotChatDetailColumns) if (!newChat) { logger.warn('Failed to create new copilot chat row', { userId, workflowId, workspaceId }) @@ -315,7 +305,7 @@ export async function resolveOrCreateChat(params: { return { chatId: newChat.id, - chat: newChat, + chat: { ...newChat, messages: [] }, conversationHistory: [], isNew: true, } diff --git a/apps/sim/lib/copilot/chat/messages-dual-write.ts b/apps/sim/lib/copilot/chat/messages-dual-write.ts deleted file mode 100644 index 54e98afe3a..0000000000 --- a/apps/sim/lib/copilot/chat/messages-dual-write.ts +++ /dev/null @@ -1,143 +0,0 @@ -import { db } from '@sim/db' -import { copilotMessages } from '@sim/db/schema' -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import { and, eq, notInArray, sql } from 'drizzle-orm' -import type { PersistedMessage } from '@/lib/copilot/chat/persisted-message' - -const logger = createLogger('CopilotMessagesDualWrite') - -/** - * Keep the first occurrence of each message id. A single `INSERT ... ON - * CONFLICT` cannot touch the same conflict target twice, so a repeated id - * would otherwise throw. - */ -function dedupeById(messages: PersistedMessage[]): PersistedMessage[] { - const seen = new Set() - const out: PersistedMessage[] = [] - for (const m of messages) { - if (seen.has(m.id)) continue - seen.add(m.id) - out.push(m) - } - return out -} - -function toRow( - chatId: string, - message: PersistedMessage, - seq: number, - options?: { chatModel?: string | null; streamId?: string | null } -): typeof copilotMessages.$inferInsert { - const ts = new Date(message.timestamp) - return { - chatId, - messageId: message.id, - role: message.role, - content: message, - seq, - model: options?.chatModel ?? null, - streamId: options?.streamId ?? null, - createdAt: ts, - updatedAt: ts, - } -} - -/** - * Append messages to the new `copilot_messages` table. Best-effort — errors - * are logged but never thrown; the legacy `copilot_chats.messages` JSONB - * column stays the source of truth during the dual-write rollout. - * - * `seq` is `MAX(seq) + index`, computed in JS (not in SQL, where every row of - * a multi-row INSERT would read the same pre-insert MAX and collide). The - * read-then-insert is non-atomic, so interleaved appends to one chat can tie - * `seq`; that window is bounded by the cutover read order (`seq, created_at, - * id`) and `replaceCopilotChatMessages`, which re-densifies `seq` from the - * authoritative JSONB order on the next snapshot save. - */ -export async function appendCopilotChatMessages( - chatId: string, - messages: PersistedMessage[], - options?: { chatModel?: string | null; streamId?: string | null } -): Promise { - if (messages.length === 0) return - try { - const deduped = dedupeById(messages) - const [maxRow] = await db - .select({ maxSeq: sql`max(${copilotMessages.seq})` }) - .from(copilotMessages) - .where(eq(copilotMessages.chatId, chatId)) - const base = (maxRow?.maxSeq ?? -1) + 1 - await db - .insert(copilotMessages) - .values(deduped.map((m, i) => toRow(chatId, m, base + i, options))) - .onConflictDoUpdate({ - target: [copilotMessages.chatId, copilotMessages.messageId], - set: { - content: sql`excluded.content`, - role: sql`excluded.role`, - model: sql`COALESCE(excluded.model, ${copilotMessages.model})`, - streamId: sql`COALESCE(excluded.stream_id, ${copilotMessages.streamId})`, - seq: sql`COALESCE(${copilotMessages.seq}, excluded.seq)`, - updatedAt: sql`now()`, - }, - }) - } catch (err) { - logger.warn('Failed to append copilot chat messages', { - chatId, - messageCount: messages.length, - error: getErrorMessage(err), - }) - } -} - -/** - * Replace all messages for a chat. Used by the update-messages endpoint that - * receives a full snapshot of the conversation state. Best-effort. - */ -export async function replaceCopilotChatMessages( - chatId: string, - messages: PersistedMessage[], - options?: { chatModel?: string | null } -): Promise { - try { - const deduped = dedupeById(messages) - const newMessageIds = deduped.map((m) => m.id) - await db.transaction(async (tx) => { - // Drop rows for messages not in the new snapshot. - await tx - .delete(copilotMessages) - .where( - newMessageIds.length > 0 - ? and( - eq(copilotMessages.chatId, chatId), - notInArray(copilotMessages.messageId, newMessageIds) - ) - : eq(copilotMessages.chatId, chatId) - ) - if (deduped.length === 0) return - // Snapshot is authoritative on order, so seq = array index is overwritten - // on conflict; stream_id / model are preserved via COALESCE. - await tx - .insert(copilotMessages) - .values(deduped.map((m, i) => toRow(chatId, m, i, options))) - .onConflictDoUpdate({ - target: [copilotMessages.chatId, copilotMessages.messageId], - set: { - content: sql`excluded.content`, - role: sql`excluded.role`, - model: sql`COALESCE(excluded.model, ${copilotMessages.model})`, - streamId: sql`COALESCE(excluded.stream_id, ${copilotMessages.streamId})`, - seq: sql`excluded.seq`, - updatedAt: sql`now()`, - }, - }) - }) - } catch (err) { - logger.warn('Failed to replace copilot chat messages', { - chatId, - messageCount: messages.length, - error: getErrorMessage(err), - }) - } -} diff --git a/apps/sim/lib/copilot/chat/messages-dual-write.test.ts b/apps/sim/lib/copilot/chat/messages-store.test.ts similarity index 95% rename from apps/sim/lib/copilot/chat/messages-dual-write.test.ts rename to apps/sim/lib/copilot/chat/messages-store.test.ts index 17d3e1666c..a96cff1250 100644 --- a/apps/sim/lib/copilot/chat/messages-dual-write.test.ts +++ b/apps/sim/lib/copilot/chat/messages-store.test.ts @@ -9,7 +9,7 @@ vi.mock('@sim/db', () => dbChainMock) import { appendCopilotChatMessages, replaceCopilotChatMessages, -} from '@/lib/copilot/chat/messages-dual-write' +} from '@/lib/copilot/chat/messages-store' import type { PersistedMessage } from '@/lib/copilot/chat/persisted-message' const userMsg: PersistedMessage = { @@ -32,7 +32,7 @@ function lastValuesRows() { return calls[calls.length - 1][0] as Array> } -describe('messages-dual-write', () => { +describe('messages-store', () => { beforeEach(() => { vi.clearAllMocks() resetDbChainMock() @@ -124,10 +124,12 @@ describe('messages-dual-write', () => { expect(rows[0].messageId).toBe('msg-user-1') }) - it('swallows DB errors so the legacy JSONB write stays canonical', async () => { + it('propagates DB errors — copilot_messages is the sole store', async () => { dbChainMockFns.onConflictDoUpdate.mockRejectedValueOnce(new Error('connection lost')) - await expect(appendCopilotChatMessages('chat-1', [userMsg])).resolves.toBeUndefined() + await expect(appendCopilotChatMessages('chat-1', [userMsg])).rejects.toThrow( + 'connection lost' + ) }) }) @@ -185,10 +187,10 @@ describe('messages-dual-write', () => { expect(rows[0].model).toBe('gpt-4o-mini') }) - it('swallows DB errors so the legacy JSONB write stays canonical', async () => { + it('propagates DB errors — the snapshot is authoritative', async () => { dbChainMockFns.transaction.mockRejectedValueOnce(new Error('tx aborted')) - await expect(replaceCopilotChatMessages('chat-1', [userMsg])).resolves.toBeUndefined() + await expect(replaceCopilotChatMessages('chat-1', [userMsg])).rejects.toThrow('tx aborted') }) }) }) diff --git a/apps/sim/lib/copilot/chat/messages-store.ts b/apps/sim/lib/copilot/chat/messages-store.ts new file mode 100644 index 0000000000..485a76ead3 --- /dev/null +++ b/apps/sim/lib/copilot/chat/messages-store.ts @@ -0,0 +1,122 @@ +import { db } from '@sim/db' +import { copilotMessages } from '@sim/db/schema' +import { and, eq, notInArray, sql } from 'drizzle-orm' +import type { PersistedMessage } from '@/lib/copilot/chat/persisted-message' +import type { DbOrTx } from '@/lib/db/types' + +/** + * Keep the first occurrence of each message id. A single `INSERT ... ON + * CONFLICT` cannot touch the same conflict target twice, so a repeated id + * would otherwise throw. + */ +function dedupeById(messages: PersistedMessage[]): PersistedMessage[] { + const seen = new Set() + const out: PersistedMessage[] = [] + for (const m of messages) { + if (seen.has(m.id)) continue + seen.add(m.id) + out.push(m) + } + return out +} + +function toRow( + chatId: string, + message: PersistedMessage, + seq: number, + options?: { chatModel?: string | null; streamId?: string | null } +): typeof copilotMessages.$inferInsert { + const ts = new Date(message.timestamp) + return { + chatId, + messageId: message.id, + role: message.role, + content: message, + seq, + model: options?.chatModel ?? null, + streamId: options?.streamId ?? null, + createdAt: ts, + updatedAt: ts, + } +} + +/** + * Append messages to the `copilot_messages` table — the sole store for chat + * transcripts. Throws on failure (a swallowed write would lose messages). + * Pass `executor` to enlist the write in an existing transaction. + * + * `seq` is `MAX(seq) + index`, computed in JS. The read-then-insert is + * non-atomic, but per-chat appends are serialized by the pending-stream lock + * and the `seq, created_at, id` read order breaks any residual tie. + */ +export async function appendCopilotChatMessages( + chatId: string, + messages: PersistedMessage[], + options?: { chatModel?: string | null; streamId?: string | null }, + executor: DbOrTx = db +): Promise { + if (messages.length === 0) return + const deduped = dedupeById(messages) + const [maxRow] = await executor + .select({ maxSeq: sql`max(${copilotMessages.seq})` }) + .from(copilotMessages) + .where(eq(copilotMessages.chatId, chatId)) + const base = (maxRow?.maxSeq ?? -1) + 1 + await executor + .insert(copilotMessages) + .values(deduped.map((m, i) => toRow(chatId, m, base + i, options))) + .onConflictDoUpdate({ + target: [copilotMessages.chatId, copilotMessages.messageId], + set: { + content: sql`excluded.content`, + role: sql`excluded.role`, + model: sql`COALESCE(excluded.model, ${copilotMessages.model})`, + streamId: sql`COALESCE(excluded.stream_id, ${copilotMessages.streamId})`, + seq: sql`COALESCE(${copilotMessages.seq}, excluded.seq)`, + updatedAt: sql`now()`, + }, + }) +} + +/** + * Replace all messages for a chat from a full snapshot (used by update-messages). + * Throws on failure. Pass `executor` to enlist the delete+insert in an existing + * transaction; otherwise it runs in its own. + */ +export async function replaceCopilotChatMessages( + chatId: string, + messages: PersistedMessage[], + options?: { chatModel?: string | null }, + executor?: DbOrTx +): Promise { + const deduped = dedupeById(messages) + const newMessageIds = deduped.map((m) => m.id) + const run = async (tx: DbOrTx) => { + await tx + .delete(copilotMessages) + .where( + newMessageIds.length > 0 + ? and( + eq(copilotMessages.chatId, chatId), + notInArray(copilotMessages.messageId, newMessageIds) + ) + : eq(copilotMessages.chatId, chatId) + ) + if (deduped.length === 0) return + await tx + .insert(copilotMessages) + .values(deduped.map((m, i) => toRow(chatId, m, i, options))) + .onConflictDoUpdate({ + target: [copilotMessages.chatId, copilotMessages.messageId], + set: { + content: sql`excluded.content`, + role: sql`excluded.role`, + model: sql`COALESCE(excluded.model, ${copilotMessages.model})`, + streamId: sql`COALESCE(excluded.stream_id, ${copilotMessages.streamId})`, + seq: sql`excluded.seq`, + updatedAt: sql`now()`, + }, + }) + } + await (executor ? run(executor) : db.transaction(run)) +} diff --git a/apps/sim/lib/copilot/chat/post.test.ts b/apps/sim/lib/copilot/chat/post.test.ts index 8b937704ac..c2271ece05 100644 --- a/apps/sim/lib/copilot/chat/post.test.ts +++ b/apps/sim/lib/copilot/chat/post.test.ts @@ -28,6 +28,7 @@ const { releasePendingChatStream, resolveOrCreateChat, finalizeAssistantTurn, + appendCopilotChatMessages, mockPublishStatusChanged, } = vi.hoisted(() => ({ getEffectiveDecryptedEnv: vi.fn(), @@ -41,6 +42,7 @@ const { releasePendingChatStream: vi.fn(), resolveOrCreateChat: vi.fn(), finalizeAssistantTurn: vi.fn(), + appendCopilotChatMessages: vi.fn(), mockPublishStatusChanged: vi.fn(), })) @@ -86,30 +88,40 @@ vi.mock('@/lib/copilot/chat/terminal-state', () => ({ finalizeAssistantTurn, })) +vi.mock('@/lib/copilot/chat/messages-store', () => ({ + appendCopilotChatMessages, +})) + vi.mock('@/lib/copilot/tasks', () => ({ taskPubSub: { publishStatusChanged: mockPublishStatusChanged, }, })) -vi.mock('@sim/db', () => ({ - db: { - update: vi.fn(() => ({ - set: vi.fn(() => ({ - where: vi.fn(() => ({ - returning: vi.fn().mockResolvedValue([]), - })), +vi.mock('@sim/db', () => { + const update = vi.fn(() => ({ + set: vi.fn(() => ({ + where: vi.fn(() => ({ + returning: vi.fn().mockResolvedValue([]), })), })), - select: vi.fn(() => ({ - from: vi.fn(() => ({ - where: vi.fn(() => ({ - limit: vi.fn().mockResolvedValue([{ permissionType: 'write' }]), - })), + })) + const select = vi.fn(() => ({ + from: vi.fn(() => ({ + where: vi.fn(() => ({ + limit: vi.fn().mockResolvedValue([{ permissionType: 'write' }]), })), })), - }, -})) + })) + return { + db: { + update, + select, + transaction: async (cb: (tx: { update: typeof update; select: typeof select }) => unknown) => + cb({ update, select }), + }, + } +}) vi.mock('drizzle-orm', () => ({ and: vi.fn(() => ({})), diff --git a/apps/sim/lib/copilot/chat/post.ts b/apps/sim/lib/copilot/chat/post.ts index 9c1d5950d0..31a0c5fce9 100644 --- a/apps/sim/lib/copilot/chat/post.ts +++ b/apps/sim/lib/copilot/chat/post.ts @@ -4,13 +4,13 @@ import { copilotChats, permissions } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' -import { and, eq, sql } from 'drizzle-orm' +import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { isZodError, validationErrorResponse } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { type ChatLoadResult, resolveOrCreateChat } from '@/lib/copilot/chat/lifecycle' -import { appendCopilotChatMessages } from '@/lib/copilot/chat/messages-dual-write' +import { appendCopilotChatMessages } from '@/lib/copilot/chat/messages-store' import { buildCopilotRequestPayload } from '@/lib/copilot/chat/payload' import { buildPersistedAssistantMessage, @@ -293,7 +293,7 @@ async function persistUserMessage(params: { * span parented to the about-to-be-dropped Next.js HTTP span. */ parentOtelContext?: OtelContext -}): Promise { +}): Promise { const { chatId, userMessageId, @@ -304,7 +304,7 @@ async function persistUserMessage(params: { notifyWorkspaceStatus, parentOtelContext, } = params - if (!chatId) return undefined + if (!chatId) return return withCopilotSpan( TraceSpan.CopilotChatPersistUserMessage, @@ -326,31 +326,32 @@ async function persistUserMessage(params: { contexts, }) - const [updated] = await db - .update(copilotChats) - .set({ - messages: sql`${copilotChats.messages} || ${JSON.stringify([userMsg])}::jsonb`, - conversationId: userMessageId, - updatedAt: new Date(), - }) - .where(eq(copilotChats.id, chatId)) - .returning({ messages: copilotChats.messages, model: copilotChats.model }) + const updated = await db.transaction(async (tx) => { + const [row] = await tx + .update(copilotChats) + .set({ + conversationId: userMessageId, + updatedAt: new Date(), + }) + .where(eq(copilotChats.id, chatId)) + .returning({ model: copilotChats.model }) - if (updated) { - await appendCopilotChatMessages(chatId, [userMsg], { - streamId: userMessageId, - chatModel: updated.model ?? null, - }) - } + if (!row) return null - const messagesAfter = Array.isArray(updated?.messages) ? updated.messages : undefined - span.setAttributes({ - [TraceAttr.ChatPersistOutcome]: updated - ? CopilotChatPersistOutcome.Appended - : CopilotChatPersistOutcome.ChatNotFound, - [TraceAttr.ChatMessagesAfter]: messagesAfter?.length ?? 0, + await appendCopilotChatMessages( + chatId, + [userMsg], + { streamId: userMessageId, chatModel: row.model ?? null }, + tx + ) + return row }) + span.setAttribute( + TraceAttr.ChatPersistOutcome, + updated ? CopilotChatPersistOutcome.Appended : CopilotChatPersistOutcome.ChatNotFound + ) + if (notifyWorkspaceStatus && updated && workspaceId) { taskPubSub?.publishStatusChanged({ workspaceId, @@ -359,8 +360,6 @@ async function persistUserMessage(params: { streamId: userMessageId, }) } - - return messagesAfter }, parentOtelContext ) @@ -885,7 +884,7 @@ export async function handleUnifiedChatPost(req: NextRequest) { }), activeOtelRoot.context ) - const persistedMessagesPromise = persistUserMessage({ + const persistUserMessagePromise = persistUserMessage({ chatId: actualChatId, userMessageId, message: body.message, @@ -908,24 +907,17 @@ export async function handleUnifiedChatPost(req: NextRequest) { activeOtelRoot.context ) - const [agentContexts, userPermission, workspaceContext, persistedMessages, executionContext] = + const [agentContexts, userPermission, workspaceContext, , executionContext] = await Promise.all([ agentContextsPromise, userPermissionPromise, workspaceContextPromise, - persistedMessagesPromise, + persistUserMessagePromise, executionContextPromise, ]) executionContext.userPermission = userPermission ?? undefined - if (persistedMessages) { - conversationHistory = persistedMessages.filter((message) => { - const record = message as Record - return record.id !== userMessageId - }) - } - // buildPayload is the last synchronous step before the outbound // Sim → Go HTTP call. It runs per-tool schema generation (subscription // lookup + registry iteration, cached 30s) and file upload tracking diff --git a/apps/sim/lib/copilot/chat/terminal-state.test.ts b/apps/sim/lib/copilot/chat/terminal-state.test.ts index cf4a230bf3..7adb8e42ae 100644 --- a/apps/sim/lib/copilot/chat/terminal-state.test.ts +++ b/apps/sim/lib/copilot/chat/terminal-state.test.ts @@ -3,212 +3,145 @@ */ import { copilotChats } from '@sim/db/schema' +import { dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing' import { eq } from 'drizzle-orm' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { - selectForUpdate, - selectLimit, - selectWhere, - selectFrom, - select, - updateWhere, - updateSet, - update, - transaction, -} = vi.hoisted(() => { - const selectLimit = vi.fn() - const selectForUpdate = vi.fn(() => ({ limit: selectLimit })) - const selectWhere = vi.fn(() => ({ for: selectForUpdate })) - const selectFrom = vi.fn(() => ({ where: selectWhere })) - const select = vi.fn(() => ({ from: selectFrom })) +vi.mock('@sim/db', () => dbChainMock) - const updateWhere = vi.fn() - const updateSet = vi.fn(() => ({ where: updateWhere })) - const update = vi.fn(() => ({ set: updateSet })) +const { mockAppendCopilotChatMessages } = vi.hoisted(() => ({ + mockAppendCopilotChatMessages: vi.fn(), +})) - const transaction = vi.fn( - (callback: (tx: { select: typeof select; update: typeof update }) => unknown) => - callback({ select, update }) - ) - - return { - selectForUpdate, - selectLimit, - selectWhere, - selectFrom, - select, - updateWhere, - updateSet, - update, - transaction, - } -}) - -vi.mock('@sim/db', () => ({ - db: { - transaction, - }, +vi.mock('@/lib/copilot/chat/messages-store', () => ({ + appendCopilotChatMessages: mockAppendCopilotChatMessages, })) import { finalizeAssistantTurn } from './terminal-state' +const assistantMessage = { + id: 'assistant-1', + role: 'assistant' as const, + content: 'hi', + timestamp: '2024-01-01T00:00:00.000Z', +} + +/** + * Sequence the two in-tx reads: the chat row (`FOR UPDATE ... LIMIT 1`) and the + * last-message lookup that drives dedup — both terminate on `.limit(1)`. + */ +function mockReads(opts: { + chat: Record | null + last?: { messageId: string; role: string } +}) { + dbChainMockFns.limit.mockResolvedValueOnce(opts.chat ? [opts.chat] : []) + dbChainMockFns.limit.mockResolvedValueOnce(opts.last ? [opts.last] : []) +} + describe('finalizeAssistantTurn', () => { beforeEach(() => { vi.clearAllMocks() - updateWhere.mockResolvedValue(undefined) + // Drain the once-queue (clearAllMocks/resetDbChainMock don't), then restore defaults. + dbChainMockFns.limit.mockReset() + resetDbChainMock() }) - it('appends the assistant message when the user turn is still last', async () => { - selectLimit.mockResolvedValue([ - { - messages: [{ id: 'user-1', role: 'user', content: 'hello' }], - conversationId: 'user-1', - workspaceId: 'ws-1', - }, - ]) - - await finalizeAssistantTurn({ - chatId: 'chat-1', - userMessageId: 'user-1', - assistantMessage: { - id: 'assistant-1', - role: 'assistant', - content: 'hi', - timestamp: '2024-01-01T00:00:00.000Z', - }, + it('appends the assistant message when the user turn has no reply yet', async () => { + mockReads({ + chat: { conversationId: 'user-1', workspaceId: 'ws-1', model: null }, + last: { messageId: 'user-1', role: 'user' }, }) - expect(updateSet).toHaveBeenCalledWith( - expect.objectContaining({ - updatedAt: expect.any(Date), - conversationId: null, - messages: expect.anything(), - }) - ) - expect(updateWhere).toHaveBeenCalledWith(eq(copilotChats.id, 'chat-1')) - }) - - it('only clears the active stream marker when a response is already persisted', async () => { - selectLimit.mockResolvedValue([ - { - messages: [ - { id: 'user-1', role: 'user', content: 'hello' }, - { id: 'assistant-1', role: 'assistant', content: 'partial' }, - ], - conversationId: 'user-1', - workspaceId: 'ws-1', - }, - ]) - - await finalizeAssistantTurn({ - chatId: 'chat-1', - userMessageId: 'user-1', - assistantMessage: { - id: 'assistant-2', - role: 'assistant', - content: 'final', - timestamp: '2024-01-01T00:00:00.000Z', - }, - }) - - const updateCalls = updateSet.mock.calls as unknown as Array<[Record]> - const updateArg = updateCalls[0]?.[0] - expect(updateArg).toBeDefined() - if (!updateArg) { - throw new Error('Expected updateSet to be called') - } - expect(updateArg).toEqual( - expect.objectContaining({ - updatedAt: expect.any(Date), - conversationId: null, - }) - ) - expect(Object.hasOwn(updateArg, 'messages')).toBe(false) - expect(updateWhere).toHaveBeenCalledWith(eq(copilotChats.id, 'chat-1')) - }) - - it('appends a stopped assistant when the stream marker was already cleared', async () => { - selectLimit.mockResolvedValue([ - { - messages: [{ id: 'user-1', role: 'user', content: 'hello' }], - conversationId: null, - workspaceId: 'ws-1', - }, - ]) - const result = await finalizeAssistantTurn({ chatId: 'chat-1', userMessageId: 'user-1', - streamMarkerPolicy: 'active-or-cleared', - assistantMessage: { - id: 'assistant-1', - role: 'assistant', - content: 'partial', - timestamp: '2024-01-01T00:00:00.000Z', - }, + assistantMessage, }) expect(result.appendedAssistant).toBe(true) - expect(updateSet).toHaveBeenCalledWith( - expect.objectContaining({ - updatedAt: expect.any(Date), - conversationId: null, - messages: expect.anything(), - }) + const updateArg = dbChainMockFns.set.mock.calls[0]?.[0] as Record + expect(updateArg).toEqual( + expect.objectContaining({ updatedAt: expect.any(Date), conversationId: null }) + ) + expect(Object.hasOwn(updateArg, 'messages')).toBe(false) + expect(dbChainMockFns.where).toHaveBeenCalledWith(eq(copilotChats.id, 'chat-1')) + expect(mockAppendCopilotChatMessages).toHaveBeenCalledTimes(1) + expect(mockAppendCopilotChatMessages).toHaveBeenCalledWith( + 'chat-1', + [assistantMessage], + { streamId: 'user-1', chatModel: null }, + expect.anything() ) }) - it('does not append on a cleared marker unless the policy allows it', async () => { - selectLimit.mockResolvedValue([ - { - messages: [{ id: 'user-1', role: 'user', content: 'hello' }], - conversationId: null, - workspaceId: 'ws-1', - }, - ]) + it('only clears the active stream marker when a response is already persisted', async () => { + mockReads({ + chat: { conversationId: 'user-1', workspaceId: 'ws-1', model: null }, + last: { messageId: 'assistant-1', role: 'assistant' }, + }) const result = await finalizeAssistantTurn({ chatId: 'chat-1', userMessageId: 'user-1', - assistantMessage: { - id: 'assistant-1', - role: 'assistant', - content: 'partial', - timestamp: '2024-01-01T00:00:00.000Z', - }, + assistantMessage: { ...assistantMessage, id: 'assistant-2' }, }) - expect(result.updated).toBe(false) - expect(updateSet).not.toHaveBeenCalled() + expect(result.outcome).toBe('assistant_already_persisted') + const updateArg = dbChainMockFns.set.mock.calls[0]?.[0] as Record + expect(updateArg).toEqual( + expect.objectContaining({ updatedAt: expect.any(Date), conversationId: null }) + ) + expect(Object.hasOwn(updateArg, 'messages')).toBe(false) + expect(mockAppendCopilotChatMessages).not.toHaveBeenCalled() }) - it('reports already persisted when a cleared marker races with a duplicate stop', async () => { - selectLimit.mockResolvedValue([ - { - messages: [ - { id: 'user-1', role: 'user', content: 'hello' }, - { id: 'assistant-1', role: 'assistant', content: 'partial' }, - ], - conversationId: null, - workspaceId: 'ws-1', - }, - ]) + it('appends a stopped assistant when the stream marker was already cleared', async () => { + mockReads({ + chat: { conversationId: null, workspaceId: 'ws-1', model: null }, + last: { messageId: 'user-1', role: 'user' }, + }) const result = await finalizeAssistantTurn({ chatId: 'chat-1', userMessageId: 'user-1', streamMarkerPolicy: 'active-or-cleared', - assistantMessage: { - id: 'assistant-2', - role: 'assistant', - content: 'partial', - timestamp: '2024-01-01T00:00:00.000Z', - }, + assistantMessage, + }) + + expect(result.appendedAssistant).toBe(true) + expect(mockAppendCopilotChatMessages).toHaveBeenCalledTimes(1) + }) + + it('does not append on a cleared marker unless the policy allows it', async () => { + mockReads({ chat: { conversationId: null, workspaceId: 'ws-1', model: null } }) + + const result = await finalizeAssistantTurn({ + chatId: 'chat-1', + userMessageId: 'user-1', + assistantMessage, + }) + + expect(result.updated).toBe(false) + expect(dbChainMockFns.set).not.toHaveBeenCalled() + expect(mockAppendCopilotChatMessages).not.toHaveBeenCalled() + }) + + it('reports already persisted when a cleared marker races with a duplicate stop', async () => { + mockReads({ + chat: { conversationId: null, workspaceId: 'ws-1', model: null }, + last: { messageId: 'assistant-1', role: 'assistant' }, + }) + + const result = await finalizeAssistantTurn({ + chatId: 'chat-1', + userMessageId: 'user-1', + streamMarkerPolicy: 'active-or-cleared', + assistantMessage: { ...assistantMessage, id: 'assistant-2' }, }) expect(result.updated).toBe(false) expect(result.outcome).toBe('assistant_already_persisted') - expect(updateSet).not.toHaveBeenCalled() + expect(dbChainMockFns.set).not.toHaveBeenCalled() + expect(mockAppendCopilotChatMessages).not.toHaveBeenCalled() }) }) diff --git a/apps/sim/lib/copilot/chat/terminal-state.ts b/apps/sim/lib/copilot/chat/terminal-state.ts index 8b4fdf11fe..5ff7886b42 100644 --- a/apps/sim/lib/copilot/chat/terminal-state.ts +++ b/apps/sim/lib/copilot/chat/terminal-state.ts @@ -1,7 +1,7 @@ import { db } from '@sim/db' -import { copilotChats } from '@sim/db/schema' -import { and, eq, sql } from 'drizzle-orm' -import { appendCopilotChatMessages } from '@/lib/copilot/chat/messages-dual-write' +import { copilotChats, copilotMessages } from '@sim/db/schema' +import { and, desc, eq, isNull, sql } from 'drizzle-orm' +import { appendCopilotChatMessages } from '@/lib/copilot/chat/messages-store' import type { PersistedMessage } from '@/lib/copilot/chat/persisted-message' import { CopilotChatFinalizeOutcome } from '@/lib/copilot/generated/trace-attribute-values-v1' import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1' @@ -48,15 +48,12 @@ export async function finalizeAssistantTurn({ [TraceAttr.ChatHasAssistantMessage]: !!assistantMessage, }, async (span) => { - let appendedAssistantMessage: PersistedMessage | undefined - let chatModel: string | null = null const result = await db.transaction(async (tx) => { const where = userId ? and(eq(copilotChats.id, chatId), eq(copilotChats.userId, userId)) : eq(copilotChats.id, chatId) const [row] = await tx .select({ - messages: copilotChats.messages, conversationId: copilotChats.conversationId, workspaceId: copilotChats.workspaceId, model: copilotChats.model, @@ -65,10 +62,6 @@ export async function finalizeAssistantTurn({ .where(where) .for('update') .limit(1) - chatModel = row?.model ?? null - - const messages: Record[] = Array.isArray(row?.messages) ? row.messages : [] - span.setAttribute(TraceAttr.ChatExistingMessageCount, messages.length) if (!row) { return { @@ -80,6 +73,8 @@ export async function finalizeAssistantTurn({ } } + const chatModel = row.model ?? null + const markerMatches = row.conversationId === userMessageId const markerAlreadyCleared = row.conversationId === null const ownsTurn = @@ -94,13 +89,20 @@ export async function finalizeAssistantTurn({ } } - const userIdx = messages.findIndex((message) => message.id === userMessageId) - const alreadyHasResponse = - userIdx >= 0 && - userIdx + 1 < messages.length && - (messages[userIdx + 1] as Record)?.role === 'assistant' - const canAppendAssistant = - userIdx >= 0 && userIdx === messages.length - 1 && !alreadyHasResponse + // Append only when the user message is still the last row: anything + // after it means the turn already has a response (dedup under the lock). + const [lastMessage] = await tx + .select({ messageId: copilotMessages.messageId, role: copilotMessages.role }) + .from(copilotMessages) + .where(and(eq(copilotMessages.chatId, chatId), isNull(copilotMessages.deletedAt))) + .orderBy( + sql`${copilotMessages.seq} desc nulls last`, + desc(copilotMessages.createdAt), + desc(copilotMessages.id) + ) + .limit(1) + const canAppendAssistant = lastMessage?.messageId === userMessageId + const alreadyHasResponse = lastMessage?.role === 'assistant' const updateWhere = userId ? and(eq(copilotChats.id, chatId), eq(copilotChats.userId, userId)) @@ -111,14 +113,13 @@ export async function finalizeAssistantTurn({ } if (assistantMessage && canAppendAssistant) { - await tx - .update(copilotChats) - .set({ - ...baseUpdate, - messages: sql`${copilotChats.messages} || ${JSON.stringify([assistantMessage])}::jsonb`, - }) - .where(updateWhere) - appendedAssistantMessage = assistantMessage + await tx.update(copilotChats).set(baseUpdate).where(updateWhere) + await appendCopilotChatMessages( + chatId, + [assistantMessage], + { streamId: userMessageId, chatModel }, + tx + ) return { found: true, updated: true, @@ -136,9 +137,7 @@ export async function finalizeAssistantTurn({ appendedAssistant: false, workspaceId: row.workspaceId, outcome: assistantMessage - ? alreadyHasResponse - ? CopilotChatFinalizeOutcome.AssistantAlreadyPersisted - : CopilotChatFinalizeOutcome.StaleUserMessage + ? CopilotChatFinalizeOutcome.AssistantAlreadyPersisted : CopilotChatFinalizeOutcome.ClearedStreamMarkerOnly, } } @@ -154,13 +153,6 @@ export async function finalizeAssistantTurn({ } }) - if (appendedAssistantMessage) { - await appendCopilotChatMessages(chatId, [appendedAssistantMessage], { - streamId: userMessageId, - chatModel, - }) - } - span.setAttribute(TraceAttr.ChatFinalizeOutcome, result.outcome) return result } diff --git a/apps/sim/lib/copilot/vfs/workspace-vfs.ts b/apps/sim/lib/copilot/vfs/workspace-vfs.ts index 74fb0735a5..5823e1b644 100644 --- a/apps/sim/lib/copilot/vfs/workspace-vfs.ts +++ b/apps/sim/lib/copilot/vfs/workspace-vfs.ts @@ -1175,27 +1175,32 @@ export class WorkspaceVFS { .select({ id: copilotChats.id, title: copilotChats.title, - messageCount: sql`COALESCE(jsonb_array_length(${copilotChats.messages}), 0)`, + messageCount: sql`COALESCE(( + SELECT COUNT(*) FROM copilot_messages cm + WHERE cm.chat_id = ${copilotChats.id} AND cm.deleted_at IS NULL + ), 0)`, messages: sql`COALESCE(( SELECT jsonb_agg( jsonb_build_object( - 'role', m.value->>'role', - 'content', m.value->'content', + 'role', cm.content->>'role', + 'content', cm.content->'content', 'contentBlocks', COALESCE(( SELECT jsonb_agg(jsonb_build_object('type', 'text', 'content', b.value->'content') ORDER BY b.ord) FROM jsonb_array_elements( - CASE WHEN jsonb_typeof(m.value->'contentBlocks') = 'array' - THEN m.value->'contentBlocks' + CASE WHEN jsonb_typeof(cm.content->'contentBlocks') = 'array' + THEN cm.content->'contentBlocks' ELSE '[]'::jsonb END ) WITH ORDINALITY AS b(value, ord) WHERE b.value->>'type' = 'text' ), '[]'::jsonb) ) - ORDER BY m.ord + ORDER BY cm.seq ASC NULLS LAST, cm.created_at ASC, cm.id ASC ) - FROM jsonb_array_elements(${copilotChats.messages}) WITH ORDINALITY AS m(value, ord) - WHERE m.value->>'role' IN ('user', 'assistant') + FROM copilot_messages cm + WHERE cm.chat_id = ${copilotChats.id} + AND cm.deleted_at IS NULL + AND cm.content->>'role' IN ('user', 'assistant') ), '[]'::jsonb)`, createdAt: copilotChats.createdAt, updatedAt: copilotChats.updatedAt, diff --git a/apps/sim/lib/data-drains/sources/copilot-chats.ts b/apps/sim/lib/data-drains/sources/copilot-chats.ts index d1d25c2aaf..6bde4632dc 100644 --- a/apps/sim/lib/data-drains/sources/copilot-chats.ts +++ b/apps/sim/lib/data-drains/sources/copilot-chats.ts @@ -1,6 +1,6 @@ import { db } from '@sim/db' -import { copilotChats } from '@sim/db/schema' -import { and, inArray } from 'drizzle-orm' +import { copilotChats, copilotMessages } from '@sim/db/schema' +import { and, asc, inArray, isNull, sql } from 'drizzle-orm' import { decodeTimeCursor, encodeTimeCursor, @@ -10,7 +10,34 @@ import { import { getOrganizationWorkspaceIds } from '@/lib/data-drains/sources/helpers' import type { Cursor, DrainSource, SourcePageInput } from '@/lib/data-drains/types' -type CopilotChatRow = typeof copilotChats.$inferSelect +/** + * The transcript no longer lives on `copilot_chats.messages` — it is assembled + * per page from the normalized `copilot_messages` table, so `messages` is the + * ordered list of message `content` objects rather than the DB column. + */ +type CopilotChatRow = Omit & { + messages: unknown[] +} + +/** Chat metadata columns, excluding the legacy `messages` JSONB. */ +const chatColumns = { + id: copilotChats.id, + userId: copilotChats.userId, + workflowId: copilotChats.workflowId, + workspaceId: copilotChats.workspaceId, + type: copilotChats.type, + title: copilotChats.title, + model: copilotChats.model, + conversationId: copilotChats.conversationId, + previewYaml: copilotChats.previewYaml, + planArtifact: copilotChats.planArtifact, + config: copilotChats.config, + resources: copilotChats.resources, + lastSeenAt: copilotChats.lastSeenAt, + pinned: copilotChats.pinned, + createdAt: copilotChats.createdAt, + updatedAt: copilotChats.updatedAt, +} as const /** * Cursor is `createdAt` (immutable) but rows themselves are mutable — @@ -28,18 +55,42 @@ async function* pages(input: SourcePageInput): AsyncIterable { while (!input.signal.aborted) { const cursorClause = timeCursorPredicate(copilotChats.createdAt, copilotChats.id, cursor) - const rows = await db - .select() + const metaRows = await db + .select(chatColumns) .from(copilotChats) .where(and(inArray(copilotChats.workspaceId, workspaceIds), cursorClause)) .orderBy(...timeCursorOrderBy(copilotChats.createdAt, copilotChats.id)) .limit(input.chunkSize) - if (rows.length === 0) return + if (metaRows.length === 0) return + + const chatIds = metaRows.map((r) => r.id) + const messageRows = await db + .select({ chatId: copilotMessages.chatId, content: copilotMessages.content }) + .from(copilotMessages) + .where(and(inArray(copilotMessages.chatId, chatIds), isNull(copilotMessages.deletedAt))) + .orderBy( + asc(copilotMessages.chatId), + sql`${copilotMessages.seq} asc nulls last`, + asc(copilotMessages.createdAt), + asc(copilotMessages.id) + ) + const messagesByChat = new Map() + for (const m of messageRows) { + const existing = messagesByChat.get(m.chatId) + if (existing) existing.push(m.content) + else messagesByChat.set(m.chatId, [m.content]) + } + + const rows: CopilotChatRow[] = metaRows.map((r) => ({ + ...r, + messages: messagesByChat.get(r.id) ?? [], + })) + yield rows - const last = rows[rows.length - 1] + const last = metaRows[metaRows.length - 1] cursor = { ts: last.createdAt.toISOString(), id: last.id } - if (rows.length < input.chunkSize) return + if (metaRows.length < input.chunkSize) return } } diff --git a/apps/sim/lib/mothership/inbox/executor.ts b/apps/sim/lib/mothership/inbox/executor.ts index d677e13124..67ac529663 100644 --- a/apps/sim/lib/mothership/inbox/executor.ts +++ b/apps/sim/lib/mothership/inbox/executor.ts @@ -4,7 +4,7 @@ import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { and, eq, sql } from 'drizzle-orm' import { resolveOrCreateChat } from '@/lib/copilot/chat/lifecycle' -import { appendCopilotChatMessages } from '@/lib/copilot/chat/messages-dual-write' +import { appendCopilotChatMessages } from '@/lib/copilot/chat/messages-store' import { buildIntegrationToolSchemas } from '@/lib/copilot/chat/payload' import { buildPersistedAssistantMessage, @@ -227,7 +227,6 @@ export async function executeInboxTask(taskId: string): Promise { if (chatId) { await persistChatMessages( chatId, - userId, userMessageId, messageContent, { @@ -328,7 +327,6 @@ async function resolveUserId( */ async function persistChatMessages( chatId: string, - userId: string, userMessageId: string, userContent: string, result: OrchestratorResult, @@ -343,22 +341,24 @@ async function persistChatMessages( const assistantMessage = buildPersistedAssistantMessage(result) - const newMessages = JSON.stringify([userMessage, assistantMessage]) - const [updated] = await db - .update(copilotChats) - .set({ - messages: sql`COALESCE(${copilotChats.messages}, '[]'::jsonb) || ${newMessages}::jsonb`, - updatedAt: new Date(), - }) - .where(eq(copilotChats.id, chatId)) - .returning({ model: copilotChats.model }) - if (updated) { - await appendCopilotChatMessages(chatId, [userMessage, assistantMessage], { - chatModel: updated.model ?? null, - }) - } + // Best-effort: the email response is the primary deliverable, so a failure + // here is logged (in the catch below) rather than failing the task. + await db.transaction(async (tx) => { + const [updated] = await tx + .update(copilotChats) + .set({ updatedAt: new Date() }) + .where(eq(copilotChats.id, chatId)) + .returning({ model: copilotChats.model }) + if (!updated) return + await appendCopilotChatMessages( + chatId, + [userMessage, assistantMessage], + { chatModel: updated.model ?? null }, + tx + ) + }) } catch (err) { - logger.warn('Failed to persist chat messages', { + logger.error('Failed to persist chat messages', { chatId, error: getErrorMessage(err, 'Unknown error'), }) From 919fa5274bfebfbe5916ac0caa7311b7cbdbd3e1 Mon Sep 17 00:00:00 2001 From: Waleed Date: Sun, 31 May 2026 22:49:15 -0700 Subject: [PATCH 26/29] feat(tables): expand filter operators (not-contains, starts/ends-with, not-in, empty) (#4827) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add does-not-contain ($ncontains), starts-with ($startsWith), ends-with ($endsWith), not-in-array ($nin, previously executed server-side but unexposed in the UI), and is-empty/is-not-empty ($empty) filter operators end-to-end — SQL builder, condition types, query-builder converters/constants, the filter UI, the Table tools/block descriptions, and docs. Also fix correctness bugs in the filter builder surfaced by the wider operator set: - Same-column AND rules (e.g. age > 18 AND age < 65, or name startsWith 'A' AND name endsWith 'Z') silently overwrote each other because the AND group was keyed by column name. They now merge into one operator object, which also makes Filter -> rules -> Filter round-trip losslessly for multi-operator columns. - $nin values were not split into an array like $in, and textual-match values like "123" were numeric-coerced (breaking the ILIKE path). - A non-boolean $empty operand from the raw API silently inverted the check; it now coerces 'true'/'false' strings and otherwise returns a 400. --- apps/docs/content/docs/en/tools/table.mdx | 6 +- .../components/table-filter/table-filter.tsx | 30 +++-- apps/sim/blocks/blocks/table.ts | 8 ++ apps/sim/lib/table/__tests__/sql.test.ts | 66 ++++++++++ .../__tests__/converters.test.ts | 119 ++++++++++++++++++ apps/sim/lib/table/query-builder/constants.ts | 15 ++- .../sim/lib/table/query-builder/converters.ts | 58 ++++++++- apps/sim/lib/table/sql.ts | 98 ++++++++++++++- apps/sim/lib/table/types.ts | 8 ++ apps/sim/tools/table/delete_rows_by_filter.ts | 3 +- apps/sim/tools/table/query_rows.ts | 2 +- apps/sim/tools/table/update_rows_by_filter.ts | 3 +- 12 files changed, 388 insertions(+), 28 deletions(-) create mode 100644 apps/sim/lib/table/query-builder/__tests__/converters.test.ts diff --git a/apps/docs/content/docs/en/tools/table.mdx b/apps/docs/content/docs/en/tools/table.mdx index 388005b772..e7cf599370 100644 --- a/apps/docs/content/docs/en/tools/table.mdx +++ b/apps/docs/content/docs/en/tools/table.mdx @@ -275,7 +275,11 @@ Filters use MongoDB-style operators for flexible querying: | `$lte` | Less than or equal | `{"quantity": {"$lte": 10}}` | | `$in` | In array | `{"status": {"$in": ["active", "pending"]}}` | | `$nin` | Not in array | `{"type": {"$nin": ["spam", "blocked"]}}` | -| `$contains` | String contains | `{"email": {"$contains": "@gmail.com"}}` | +| `$contains` | String contains (case-insensitive) | `{"email": {"$contains": "@gmail.com"}}` | +| `$ncontains` | Does not contain (case-insensitive; matches empty cells) | `{"email": {"$ncontains": "@spam.com"}}` | +| `$startsWith` | Starts with (case-insensitive) | `{"name": {"$startsWith": "Dr."}}` | +| `$endsWith` | Ends with (case-insensitive) | `{"file": {"$endsWith": ".pdf"}}` | +| `$empty` | Cell is empty (`true`) or non-empty (`false`) | `{"phone": {"$empty": true}}` | ### Combining Filters diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-filter/table-filter.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-filter/table-filter.tsx index bd683c6251..12f5bb10fb 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-filter/table-filter.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-filter/table-filter.tsx @@ -12,7 +12,7 @@ import { } from '@/components/emcn' import { ChevronDown, Plus } from '@/components/emcn/icons' import type { Filter, FilterRule } from '@/lib/table' -import { COMPARISON_OPERATORS } from '@/lib/table/query-builder/constants' +import { COMPARISON_OPERATORS, VALUELESS_OPERATORS } from '@/lib/table/query-builder/constants' import { filterRulesToFilter, filterToRules } from '@/lib/table/query-builder/converters' const OPERATOR_LABELS = Object.fromEntries( @@ -71,7 +71,9 @@ export function TableFilter({ columns, filter, onApply, onClose }: TableFilterPr }, []) const handleApply = useCallback(() => { - const validRules = rulesRef.current.filter((r) => r.column && r.value) + const validRules = rulesRef.current.filter( + (r) => r.column && (r.value || VALUELESS_OPERATORS.has(r.operator)) + ) onApply(filterRulesToFilter(validRules)) }, [onApply]) @@ -197,16 +199,20 @@ const FilterRuleRow = memo(function FilterRuleRow({ - onUpdate(rule.id, 'value', e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Enter') onApply() - }} - placeholder='Enter a value' - className='h-[28px] flex-1 rounded-[5px] border border-[var(--border)] bg-transparent px-2 text-[var(--text-secondary)] text-xs outline-none placeholder:text-[var(--text-subtle)] hover-hover:border-[var(--border-1)] focus:border-[var(--border-1)]' - /> + {VALUELESS_OPERATORS.has(rule.operator) ? ( +
    + ) : ( + onUpdate(rule.id, 'value', e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') onApply() + }} + placeholder='Enter a value' + className='h-[28px] flex-1 rounded-[5px] border border-[var(--border)] bg-transparent px-2 text-[var(--text-secondary)] text-xs outline-none placeholder:text-[var(--text-subtle)] hover-hover:border-[var(--border-1)] focus:border-[var(--border-1)]' + /> + )}