From 734a067e573519a2e62f0e2d94886298088e1b0e Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Wed, 29 Jul 2026 16:45:35 -0700 Subject: [PATCH 1/5] feat(newsletters): add superuser audience targeting --- .../app/account/settings/[section]/page.tsx | 6 +- apps/sim/app/account/settings/layout.tsx | 10 +- .../newsletters/runs/[id]/export.csv/route.ts | 68 + .../newsletters/runs/[id]/finalize/route.ts | 45 + .../newsletters/runs/[id]/job/route.ts | 50 + .../runs/[id]/push-resend/route.ts | 48 + .../superuser/newsletters/runs/[id]/route.ts | 29 + .../superuser/newsletters/runs/route.test.ts | 84 + .../api/superuser/newsletters/runs/route.ts | 63 + .../[workspaceId]/settings/[section]/page.tsx | 6 +- .../settings/[section]/settings.tsx | 6 + .../components/newsletters/newsletters.tsx | 318 + .../[workspaceId]/settings/navigation.test.ts | 1 + apps/sim/background/newsletter-resend-sync.ts | 19 + .../settings/account-settings-renderer.tsx | 8 +- .../components/settings/navigation.test.ts | 11 + apps/sim/components/settings/navigation.ts | 23 +- .../settings/standalone-settings-shell.tsx | 3 + apps/sim/hooks/queries/newsletters.ts | 98 + apps/sim/lib/api/contracts/newsletters.ts | 211 + .../core/async-jobs/backends/database.test.ts | 203 +- .../lib/core/async-jobs/backends/database.ts | 240 +- .../core/async-jobs/backends/trigger-dev.ts | 1 + apps/sim/lib/core/async-jobs/types.ts | 1 + apps/sim/lib/newsletters/auth.ts | 37 + apps/sim/lib/newsletters/push-resend.test.ts | 263 + apps/sim/lib/newsletters/push-resend.ts | 229 + apps/sim/lib/newsletters/resend.test.ts | 199 + apps/sim/lib/newsletters/resend.ts | 278 + apps/sim/lib/newsletters/runs.ts | 782 + apps/sim/lib/newsletters/targeting.test.ts | 90 + apps/sim/lib/newsletters/targeting.ts | 332 + .../db/migrations/0277_condemned_sandman.sql | 53 + .../db/migrations/meta/0277_snapshot.json | 18422 ++++++++++++++++ packages/db/migrations/meta/_journal.json | 7 + packages/db/schema.ts | 70 + scripts/check-api-validation-contracts.ts | 4 +- 37 files changed, 22278 insertions(+), 40 deletions(-) create mode 100644 apps/sim/app/api/superuser/newsletters/runs/[id]/export.csv/route.ts create mode 100644 apps/sim/app/api/superuser/newsletters/runs/[id]/finalize/route.ts create mode 100644 apps/sim/app/api/superuser/newsletters/runs/[id]/job/route.ts create mode 100644 apps/sim/app/api/superuser/newsletters/runs/[id]/push-resend/route.ts create mode 100644 apps/sim/app/api/superuser/newsletters/runs/[id]/route.ts create mode 100644 apps/sim/app/api/superuser/newsletters/runs/route.test.ts create mode 100644 apps/sim/app/api/superuser/newsletters/runs/route.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/settings/components/newsletters/newsletters.tsx create mode 100644 apps/sim/background/newsletter-resend-sync.ts create mode 100644 apps/sim/hooks/queries/newsletters.ts create mode 100644 apps/sim/lib/api/contracts/newsletters.ts create mode 100644 apps/sim/lib/newsletters/auth.ts create mode 100644 apps/sim/lib/newsletters/push-resend.test.ts create mode 100644 apps/sim/lib/newsletters/push-resend.ts create mode 100644 apps/sim/lib/newsletters/resend.test.ts create mode 100644 apps/sim/lib/newsletters/resend.ts create mode 100644 apps/sim/lib/newsletters/runs.ts create mode 100644 apps/sim/lib/newsletters/targeting.test.ts create mode 100644 apps/sim/lib/newsletters/targeting.ts create mode 100644 packages/db/migrations/0277_condemned_sandman.sql create mode 100644 packages/db/migrations/meta/0277_snapshot.json diff --git a/apps/sim/app/account/settings/[section]/page.tsx b/apps/sim/app/account/settings/[section]/page.tsx index 71f10cbea0e..09e428310ac 100644 --- a/apps/sim/app/account/settings/[section]/page.tsx +++ b/apps/sim/app/account/settings/[section]/page.tsx @@ -11,7 +11,7 @@ import { } from '@/components/settings/navigation' import { getSession } from '@/lib/auth' import { isBillingEnabled } from '@/lib/core/config/env-flags' -import { isPlatformAdmin } from '@/lib/permissions/super-user' +import { isPlatformAdmin, verifyEffectiveSuperUser } from '@/lib/permissions/super-user' interface AccountSettingsSectionPageProps { params: Promise<{ section: string }> @@ -50,6 +50,10 @@ export default async function AccountSettingsSectionPage({ const isSuperUser = await isPlatformAdmin(session.user.id) if (!isSuperUser) notFound() } + if (parsed === 'newsletters') { + const { effectiveSuperUser } = await verifyEffectiveSuperUser(session.user.id) + if (!effectiveSuperUser) notFound() + } /** * Sections read URL query params via nuqs (which uses `useSearchParams` diff --git a/apps/sim/app/account/settings/layout.tsx b/apps/sim/app/account/settings/layout.tsx index cafd6d2f3c8..c1c2a2f5a85 100644 --- a/apps/sim/app/account/settings/layout.tsx +++ b/apps/sim/app/account/settings/layout.tsx @@ -1,15 +1,19 @@ import { redirect } from 'next/navigation' import { StandaloneSettingsShell } from '@/components/settings/standalone-settings-shell' import { getSession } from '@/lib/auth' -import { isPlatformAdmin } from '@/lib/permissions/super-user' +import { verifyEffectiveSuperUser } from '@/lib/permissions/super-user' export default async function AccountSettingsLayout({ children }: { children: React.ReactNode }) { const session = await getSession() if (!session?.user) redirect('/login') - const isSuperUser = await isPlatformAdmin(session.user.id) + const { effectiveSuperUser, isSuperUser } = await verifyEffectiveSuperUser(session.user.id) return ( - + {children} ) diff --git a/apps/sim/app/api/superuser/newsletters/runs/[id]/export.csv/route.ts b/apps/sim/app/api/superuser/newsletters/runs/[id]/export.csv/route.ts new file mode 100644 index 00000000000..5a5b959d01d --- /dev/null +++ b/apps/sim/app/api/superuser/newsletters/runs/[id]/export.csv/route.ts @@ -0,0 +1,68 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { exportNewsletterRunCsvContract } from '@/lib/api/contracts/newsletters' +import { parseRequest } from '@/lib/api/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { validateNewsletterSuperuser } from '@/lib/newsletters/auth' +import { createNewsletterCsvExport } from '@/lib/newsletters/runs' + +const logger = createLogger('NewsletterCsvExportAPI') + +export const GET = withRouteHandler( + async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { + try { + const auth = await validateNewsletterSuperuser() + if (!auth.success) return auth.response + + const parsed = await parseRequest(exportNewsletterRunCsvContract, request, context) + if (!parsed.success) return parsed.response + + const { filename, lines } = await createNewsletterCsvExport(parsed.data.params.id) + const iterator = lines[Symbol.asyncIterator]() + const encoder = new TextEncoder() + const stream = new ReadableStream({ + async pull(controller) { + try { + const next = await iterator.next() + if (next.done) { + controller.close() + return + } + controller.enqueue(encoder.encode(`${next.value}\n`)) + } catch (error) { + logger.error('Failed while streaming newsletter CSV', { + error: getErrorMessage(error), + }) + controller.error(error) + } + }, + async cancel() { + await iterator.return?.(undefined) + }, + }) + + return new NextResponse(stream, { + status: 200, + headers: { + 'Content-Type': 'text/csv; charset=utf-8', + 'Content-Disposition': `attachment; filename="${filename}"`, + 'Cache-Control': 'no-store', + }, + }) + } catch (error) { + const message = getErrorMessage(error) + if (/not found/i.test(message)) { + return NextResponse.json({ error: 'Newsletter run not found' }, { status: 404 }) + } + if (/Finalize/i.test(message)) { + return NextResponse.json({ error: message }, { status: 400 }) + } + if (/RESEND_API_KEY|Resend .*list|Resend request/i.test(message)) { + return NextResponse.json({ error: message }, { status: 503 }) + } + logger.error('Failed to export newsletter CSV', { error: message }) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } + } +) diff --git a/apps/sim/app/api/superuser/newsletters/runs/[id]/finalize/route.ts b/apps/sim/app/api/superuser/newsletters/runs/[id]/finalize/route.ts new file mode 100644 index 00000000000..2b10eee56c9 --- /dev/null +++ b/apps/sim/app/api/superuser/newsletters/runs/[id]/finalize/route.ts @@ -0,0 +1,45 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { finalizeNewsletterRunContract } from '@/lib/api/contracts/newsletters' +import { parseRequest } from '@/lib/api/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { validateNewsletterSuperuser } from '@/lib/newsletters/auth' +import { finalizeNewsletterRun } from '@/lib/newsletters/runs' + +const logger = createLogger('NewsletterFinalizeAPI') + +export const POST = withRouteHandler( + async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { + try { + const auth = await validateNewsletterSuperuser() + if (!auth.success) return auth.response + + const parsed = await parseRequest(finalizeNewsletterRunContract, request, context) + if (!parsed.success) return parsed.response + + const { run, oversized } = await finalizeNewsletterRun(parsed.data.params.id) + if (oversized) { + logger.warn('Newsletter audience exceeded the finalization safety limit', { + userId: auth.userId, + runId: run.id, + totalMatched: run.counts.totalMatched, + }) + } + return NextResponse.json({ run }) + } catch (error) { + const message = getErrorMessage(error) + if (/not found/i.test(message)) { + return NextResponse.json({ error: 'Newsletter run not found' }, { status: 404 }) + } + if (/already in progress/i.test(message)) { + return NextResponse.json({ error: message }, { status: 409 }) + } + if (/RESEND_API_KEY|Resend .*list|Resend request/i.test(message)) { + return NextResponse.json({ error: message }, { status: 503 }) + } + logger.error('Failed to finalize newsletter run', { error: message }) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } + } +) diff --git a/apps/sim/app/api/superuser/newsletters/runs/[id]/job/route.ts b/apps/sim/app/api/superuser/newsletters/runs/[id]/job/route.ts new file mode 100644 index 00000000000..5851812be2e --- /dev/null +++ b/apps/sim/app/api/superuser/newsletters/runs/[id]/job/route.ts @@ -0,0 +1,50 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { getNewsletterRunJobContract } from '@/lib/api/contracts/newsletters' +import { parseRequest } from '@/lib/api/server' +import { getJobQueue } from '@/lib/core/async-jobs' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { validateNewsletterSuperuser } from '@/lib/newsletters/auth' +import { requireNewsletterRun } from '@/lib/newsletters/runs' + +const logger = createLogger('NewsletterJobAPI') + +export const GET = withRouteHandler( + async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { + try { + const auth = await validateNewsletterSuperuser() + if (!auth.success) return auth.response + + const parsed = await parseRequest(getNewsletterRunJobContract, request, context) + if (!parsed.success) return parsed.response + + const run = await requireNewsletterRun(parsed.data.params.id) + if (!run.resendSyncJobId) return NextResponse.json({ job: null }) + + const queue = await getJobQueue() + const job = await queue.getJob(run.resendSyncJobId) + return NextResponse.json({ + job: job + ? { + id: job.id, + status: job.status, + attempts: job.attempts, + maxAttempts: job.maxAttempts, + error: job.error ?? null, + createdAt: job.createdAt.toISOString(), + startedAt: job.startedAt?.toISOString() ?? null, + completedAt: job.completedAt?.toISOString() ?? null, + } + : null, + }) + } catch (error) { + const message = getErrorMessage(error) + if (/not found/i.test(message)) { + return NextResponse.json({ error: 'Newsletter run not found' }, { status: 404 }) + } + logger.error('Failed to get newsletter job', { error: message }) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } + } +) diff --git a/apps/sim/app/api/superuser/newsletters/runs/[id]/push-resend/route.ts b/apps/sim/app/api/superuser/newsletters/runs/[id]/push-resend/route.ts new file mode 100644 index 00000000000..64a417d3e3d --- /dev/null +++ b/apps/sim/app/api/superuser/newsletters/runs/[id]/push-resend/route.ts @@ -0,0 +1,48 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { pushNewsletterRunToResendContract } from '@/lib/api/contracts/newsletters' +import { parseRequest } from '@/lib/api/server' +import { isAsyncJobEnqueueError } from '@/lib/core/async-jobs' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { validateNewsletterSuperuser } from '@/lib/newsletters/auth' +import { enqueueNewsletterResendSync } from '@/lib/newsletters/push-resend' + +const logger = createLogger('NewsletterPushResendAPI') + +export const POST = withRouteHandler( + async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { + try { + const auth = await validateNewsletterSuperuser() + if (!auth.success) return auth.response + + const parsed = await parseRequest(pushNewsletterRunToResendContract, request, context) + if (!parsed.success) return parsed.response + + const { run, jobId } = await enqueueNewsletterResendSync(parsed.data.params.id, auth.userId) + return NextResponse.json({ run, jobId }) + } catch (error) { + const message = getErrorMessage(error) + if (/not found/i.test(message)) { + return NextResponse.json({ error: 'Newsletter run not found' }, { status: 404 }) + } + if (/finalize|RESEND_API_KEY/i.test(message)) { + return NextResponse.json({ error: message }, { status: 400 }) + } + if (isAsyncJobEnqueueError(error)) { + return NextResponse.json( + { error: 'Newsletter sync enqueue is uncertain; retry to resume the same attempt' }, + { status: 503 } + ) + } + if (/tracking persistence failed/i.test(message)) { + return NextResponse.json( + { error: 'Newsletter sync was accepted but job tracking is not yet available; retry' }, + { status: 503 } + ) + } + logger.error('Failed to enqueue newsletter Resend push', { error: message }) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } + } +) diff --git a/apps/sim/app/api/superuser/newsletters/runs/[id]/route.ts b/apps/sim/app/api/superuser/newsletters/runs/[id]/route.ts new file mode 100644 index 00000000000..ade95019807 --- /dev/null +++ b/apps/sim/app/api/superuser/newsletters/runs/[id]/route.ts @@ -0,0 +1,29 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { getNewsletterRunContract } from '@/lib/api/contracts/newsletters' +import { parseRequest } from '@/lib/api/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { validateNewsletterSuperuser } from '@/lib/newsletters/auth' +import { getNewsletterRun } from '@/lib/newsletters/runs' + +const logger = createLogger('NewsletterRunAPI') + +export const GET = withRouteHandler( + async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { + try { + const auth = await validateNewsletterSuperuser() + if (!auth.success) return auth.response + + const parsed = await parseRequest(getNewsletterRunContract, request, context) + if (!parsed.success) return parsed.response + + const run = await getNewsletterRun(parsed.data.params.id) + if (!run) return NextResponse.json({ error: 'Newsletter run not found' }, { status: 404 }) + return NextResponse.json({ run }) + } catch (error) { + logger.error('Failed to get newsletter run', { error: getErrorMessage(error) }) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } + } +) diff --git a/apps/sim/app/api/superuser/newsletters/runs/route.test.ts b/apps/sim/app/api/superuser/newsletters/runs/route.test.ts new file mode 100644 index 00000000000..d15dd362ef7 --- /dev/null +++ b/apps/sim/app/api/superuser/newsletters/runs/route.test.ts @@ -0,0 +1,84 @@ +/** + * @vitest-environment node + */ +import { createMockRequest } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockCreateNewsletterRun, mockListNewsletterRuns, mockValidateNewsletterSuperuser } = + vi.hoisted(() => ({ + mockCreateNewsletterRun: vi.fn(), + mockListNewsletterRuns: vi.fn(), + mockValidateNewsletterSuperuser: vi.fn(), + })) + +vi.mock('@/lib/newsletters/auth', () => ({ + validateNewsletterSuperuser: mockValidateNewsletterSuperuser, +})) + +vi.mock('@/lib/newsletters/runs', () => ({ + createNewsletterRun: mockCreateNewsletterRun, + listNewsletterRuns: mockListNewsletterRuns, +})) + +import { NewsletterTargetingPromptError } from '@/lib/newsletters/targeting' +import { POST } from '@/app/api/superuser/newsletters/runs/route' + +function createRequest(prompt = 'Everyone') { + return createMockRequest('POST', { + name: 'July launch', + prompt, + }) +} + +describe('newsletter runs POST', () => { + beforeEach(() => { + vi.clearAllMocks() + mockValidateNewsletterSuperuser.mockResolvedValue({ + success: true, + userId: 'user-1', + }) + }) + + it('returns the superuser authorization failure before creating a run', async () => { + mockValidateNewsletterSuperuser.mockResolvedValue({ + success: false, + response: new Response(JSON.stringify({ error: 'Forbidden' }), { status: 403 }), + }) + + const response = await POST(createRequest()) + + expect(response.status).toBe(403) + expect(mockCreateNewsletterRun).not.toHaveBeenCalled() + }) + + it('creates a preview run for an authorized superuser', async () => { + const run = { + id: 'run-1', + name: 'July launch', + prompt: 'Everyone', + } + mockCreateNewsletterRun.mockResolvedValue(run) + + const response = await POST(createRequest()) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ run }) + expect(mockCreateNewsletterRun).toHaveBeenCalledWith({ + name: 'July launch', + prompt: 'Everyone', + createdById: 'user-1', + }) + }) + + it('returns a client error for an ambiguous targeting prompt', async () => { + mockCreateNewsletterRun.mockRejectedValue(new NewsletterTargetingPromptError()) + + const response = await POST(createRequest('Users interested in productivity')) + + expect(response.status).toBe(400) + await expect(response.json()).resolves.toEqual({ + error: + 'Targeting prompt is ambiguous. Use everyone, an Instagram integration or chat target, or a recent activity window.', + }) + }) +}) diff --git a/apps/sim/app/api/superuser/newsletters/runs/route.ts b/apps/sim/app/api/superuser/newsletters/runs/route.ts new file mode 100644 index 00000000000..af910eadbc5 --- /dev/null +++ b/apps/sim/app/api/superuser/newsletters/runs/route.ts @@ -0,0 +1,63 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { + createNewsletterRunContract, + listNewsletterRunsContract, +} from '@/lib/api/contracts/newsletters' +import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { validateNewsletterSuperuser } from '@/lib/newsletters/auth' +import { createNewsletterRun, listNewsletterRuns } from '@/lib/newsletters/runs' +import { NewsletterTargetingPromptError } from '@/lib/newsletters/targeting' + +const logger = createLogger('NewsletterRunsAPI') + +export const GET = withRouteHandler(async (request: NextRequest) => { + try { + const auth = await validateNewsletterSuperuser() + if (!auth.success) return auth.response + + const parsed = await parseRequest(listNewsletterRunsContract, request, {}) + if (!parsed.success) return parsed.response + + const { limit, offset } = parsed.data.query + return NextResponse.json(await listNewsletterRuns(limit, offset)) + } catch (error) { + logger.error('Failed to list newsletter runs', { error: getErrorMessage(error) }) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } +}) + +export const POST = withRouteHandler(async (request: NextRequest) => { + try { + const auth = await validateNewsletterSuperuser() + if (!auth.success) return auth.response + + const parsed = await parseRequest( + createNewsletterRunContract, + request, + {}, + { + validationErrorResponse: (error) => + NextResponse.json( + { error: getValidationErrorMessage(error, 'Invalid newsletter run') }, + { status: 400 } + ), + } + ) + if (!parsed.success) return parsed.response + + const run = await createNewsletterRun({ + ...parsed.data.body, + createdById: auth.userId, + }) + return NextResponse.json({ run }) + } catch (error) { + if (error instanceof NewsletterTargetingPromptError) { + return NextResponse.json({ error: error.message }, { status: 400 }) + } + logger.error('Failed to create newsletter run', { error: getErrorMessage(error) }) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } +}) diff --git a/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.tsx b/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.tsx index 6303933ab1c..94af64fafeb 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.tsx @@ -15,7 +15,7 @@ import { hasWorkspaceInboxAccess } from '@/lib/billing/core/subscription' import { getEnv, isTruthy } from '@/lib/core/config/env' import { isBillingEnabled, isHosted } from '@/lib/core/config/env-flags' import { canOpenOrganizationSettingsSection } from '@/lib/organizations/settings-access' -import { isPlatformAdmin } from '@/lib/permissions/super-user' +import { isPlatformAdmin, verifyEffectiveSuperUser } from '@/lib/permissions/super-user' import { getWorkspaceHostContextForViewer } from '@/lib/workspaces/host-context' import { getQueryClient } from '@/app/_shell/providers/get-query-client' import { @@ -104,6 +104,10 @@ export default async function WorkspaceSettingsSectionPage({ if (parsed === 'admin' || parsed === 'mothership') { if (!(await isPlatformAdmin(session.user.id))) notFound() } + if (parsed === 'newsletters') { + const { effectiveSuperUser } = await verifyEffectiveSuperUser(session.user.id) + if (!effectiveSuperUser) notFound() + } const workspaceSection = WORKSPACE_SECTION_MAP[parsed] if (workspaceSection) { diff --git a/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx b/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx index b594b4c1e04..5f6197b1a92 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx @@ -45,6 +45,11 @@ const Mothership = dynamic(() => (m) => m.Mothership ) ) +const Newsletters = dynamic(() => + import('@/app/workspace/[workspaceId]/settings/components/newsletters/newsletters').then( + (m) => m.Newsletters + ) +) const RecentlyDeleted = dynamic(() => import( '@/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted' @@ -195,6 +200,7 @@ export function SettingsPage({ section }: SettingsPageProps) { {effectiveSection === 'recently-deleted' && } {effectiveSection === 'admin' && } {effectiveSection === 'mothership' && } + {effectiveSection === 'newsletters' && } ) } diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/newsletters/newsletters.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/newsletters/newsletters.tsx new file mode 100644 index 00000000000..68d3ebc77ab --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/newsletters/newsletters.tsx @@ -0,0 +1,318 @@ +'use client' + +import { useState } from 'react' +import { Badge, Chip, ChipInput, ChipLink, ChipTextarea, Label, Skeleton } from '@sim/emcn' +import { Download, RefreshCw, Send } from '@sim/emcn/icons' +import { getErrorMessage } from '@sim/utils/errors' +import { formatDateTime } from '@sim/utils/formatting' +import type { NewsletterRun } from '@/lib/api/contracts/newsletters' +import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' +import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' +import { + useCreateNewsletterRun, + useFinalizeNewsletterRun, + useNewsletterJob, + useNewsletterRuns, + usePushNewsletterRunToResend, +} from '@/hooks/queries/newsletters' + +const PRESETS = [ + { label: 'Everyone', prompt: 'Everyone' }, + { label: 'Instagram users', prompt: 'Users who use the Instagram integration' }, + { + label: 'Instagram chat context', + prompt: 'Users whose chat context mentions Instagram in the last 90 days', + }, + { label: 'Recently active', prompt: 'Users active in the last 30 days' }, +] + +function formatNumber(value: number) { + return new Intl.NumberFormat().format(value) +} + +function formatDate(value: string | null) { + if (!value) return '—' + const date = new Date(value) + if (Number.isNaN(date.getTime())) return '—' + return formatDateTime(date) +} + +function statusBadge(run: NewsletterRun) { + if (run.status === 'pushed') + return ( + + Pushed + + ) + if (run.status === 'pushing') + return ( + + Pushing + + ) + if (run.status === 'finalizing') + return ( + + Finalizing + + ) + if (run.status === 'oversized') + return ( + + Too large + + ) + if (run.status === 'failed') + return ( + + Failed + + ) + if (run.status === 'finalized') + return ( + + Finalized + + ) + return ( + + Draft + + ) +} + +function CriteriaLabel({ run }: { run: NewsletterRun }) { + const criteria = run.criteria + if (criteria.type === 'everyone') return Everyone + if (criteria.type === 'integration_users') { + return {criteria.integration} integration users + } + if (criteria.type === 'chat_mentions') return Chats mentioning {criteria.term} + return Active in the last {criteria.timeWindowDays} days +} + +function Stat({ label, value }: { label: string; value: number }) { + return ( +
+ {label} + {formatNumber(value)} +
+ ) +} + +function RunCard({ + run, + activeJob, + onFinalize, + onPush, + finalizePending, + pushPending, +}: { + run: NewsletterRun + activeJob: ReturnType['data'] + onFinalize: (id: string) => void + onPush: (id: string) => void + finalizePending: boolean + pushPending: boolean +}) { + const canFinalize = run.status === 'draft' + const canExport = ['finalized', 'pushing', 'pushed', 'failed'].includes(run.status) + const canPush = run.status === 'finalized' || run.status === 'failed' || run.status === 'pushing' + const csvHref = `/api/superuser/newsletters/runs/${encodeURIComponent(run.id)}/export.csv` + + return ( +
+
+
+
+

{run.name}

+ {statusBadge(run)} +
+
+ + {formatDate(run.createdAt)} + {run.resendSegmentName && {run.resendSegmentName}} +
+
+
+ {canFinalize && ( + onFinalize(run.id)} + disabled={finalizePending} + > + {finalizePending ? 'Finalizing...' : 'Finalize'} + + )} + {canExport && ( + + CSV + + )} + {canPush && ( + onPush(run.id)} + disabled={pushPending} + > + {pushPending ? 'Queueing...' : run.status === 'pushing' ? 'Resume queue' : 'Resend'} + + )} +
+
+ +
+ + + + + + +
+ + {run.sampleRecipients.length > 0 && ( +
+

Sample

+
+ {run.sampleRecipients.slice(0, 6).map((recipient) => ( +
+ {recipient.email} + · {recipient.reason} +
+ ))} +
+
+ )} + + {(run.error || activeJob?.job || run.resendSegmentId) && ( +
+ {activeJob?.job && Job {activeJob.job.status}} + {run.resendSegmentId && Segment {run.resendSegmentId}} + {run.resendSyncedAt && Synced {formatDate(run.resendSyncedAt)}} + {run.error && {run.error}} +
+ )} +
+ ) +} + +export function Newsletters() { + const { data, isLoading, refetch } = useNewsletterRuns() + const createRun = useCreateNewsletterRun() + const finalizeRun = useFinalizeNewsletterRun() + const pushRun = usePushNewsletterRunToResend() + const activePushRunId = + pushRun.data?.run.id ?? + data?.runs.find((run) => run.status === 'pushing' && run.resendSyncJobId)?.id + const activeJob = useNewsletterJob(activePushRunId, Boolean(activePushRunId)) + + const [name, setName] = useState('') + const [prompt, setPrompt] = useState('Everyone') + + const mutationError = createRun.error || finalizeRun.error || pushRun.error + const error = mutationError ? getErrorMessage(mutationError) : null + + const handleCreate = () => { + const trimmedName = name.trim() + const trimmedPrompt = prompt.trim() + if (!trimmedName || !trimmedPrompt) return + createRun.mutate( + { name: trimmedName, prompt: trimmedPrompt }, + { + onSuccess: () => { + setName('') + setPrompt('Everyone') + }, + } + ) + } + + return ( + +
+
+
+
+ + setName(event.target.value)} + placeholder='July launch' + /> +
+
+ + setPrompt(event.target.value)} + placeholder='Users whose chat context mentions Instagram' + rows={3} + /> +
+
+
+
+ {PRESETS.map((preset) => ( + setPrompt(preset.prompt)} + > + {preset.label} + + ))} +
+ + {createRun.isPending ? 'Previewing...' : 'Create preview'} + +
+ {error &&

{error}

} +
+ +
+

Runs

+ refetch()} disabled={isLoading}> + Refresh + +
+ + {isLoading ? ( +
+ + +
+ ) : data?.runs.length ? ( +
+ {data.runs.map((run) => ( + finalizeRun.mutate(id)} + onPush={(id) => pushRun.mutate(id)} + finalizePending={finalizeRun.isPending && finalizeRun.variables === run.id} + pushPending={pushRun.isPending && pushRun.variables === run.id} + /> + ))} +
+ ) : ( + No newsletter runs yet. + )} +
+
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/settings/navigation.test.ts b/apps/sim/app/workspace/[workspaceId]/settings/navigation.test.ts index d24163419a8..3daed59d0c8 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/navigation.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/settings/navigation.test.ts @@ -49,6 +49,7 @@ describe('unified settings navigation', () => { { id: 'custom-blocks', label: 'Custom blocks', section: 'enterprise' }, { id: 'admin', label: 'Admin', section: 'superuser' }, { id: 'mothership', label: 'Mothership', section: 'superuser' }, + { id: 'newsletters', label: 'Newsletters', section: 'superuser' }, ]) }) diff --git a/apps/sim/background/newsletter-resend-sync.ts b/apps/sim/background/newsletter-resend-sync.ts new file mode 100644 index 00000000000..860a8326d79 --- /dev/null +++ b/apps/sim/background/newsletter-resend-sync.ts @@ -0,0 +1,19 @@ +import { task } from '@trigger.dev/sdk' +import { + NEWSLETTER_RESEND_SYNC_CONCURRENCY_LIMIT, + NEWSLETTER_RESEND_SYNC_MAX_ATTEMPTS, + type NewsletterResendSyncPayload, + runNewsletterResendSync, +} from '@/lib/newsletters/push-resend' + +export const newsletterResendSyncTask = task({ + id: 'newsletter-resend-sync', + machine: 'small-1x', + retry: { + maxAttempts: NEWSLETTER_RESEND_SYNC_MAX_ATTEMPTS, + }, + queue: { + concurrencyLimit: NEWSLETTER_RESEND_SYNC_CONCURRENCY_LIMIT, + }, + run: async (payload: NewsletterResendSyncPayload) => runNewsletterResendSync(payload), +}) diff --git a/apps/sim/components/settings/account-settings-renderer.tsx b/apps/sim/components/settings/account-settings-renderer.tsx index 7cefeba5d47..9061bb5f8f0 100644 --- a/apps/sim/components/settings/account-settings-renderer.tsx +++ b/apps/sim/components/settings/account-settings-renderer.tsx @@ -27,6 +27,11 @@ const Mothership = dynamic(() => (module) => module.Mothership ) ) +const Newsletters = dynamic(() => + import('@/app/workspace/[workspaceId]/settings/components/newsletters/newsletters').then( + (module) => module.Newsletters + ) +) interface AccountSettingsRendererProps { section: AccountSettingsSection @@ -43,5 +48,6 @@ export function AccountSettingsRenderer({ section }: AccountSettingsRendererProp if (section === 'billing') return if (section === 'api-keys') return if (section === 'admin') return - return + if (section === 'mothership') return + return } diff --git a/apps/sim/components/settings/navigation.test.ts b/apps/sim/components/settings/navigation.test.ts index 0dc9a894ff9..818c893efed 100644 --- a/apps/sim/components/settings/navigation.test.ts +++ b/apps/sim/components/settings/navigation.test.ts @@ -52,6 +52,7 @@ describe('settings navigation boundaries', () => { 'custom-blocks', 'admin', 'mothership', + 'newsletters', ]) expect(ACCOUNT_SETTINGS_ITEMS.map(({ id }) => id)).toEqual([ 'general', @@ -59,6 +60,7 @@ describe('settings navigation boundaries', () => { 'api-keys', 'admin', 'mothership', + 'newsletters', ]) expect(SELFHOST_SETTINGS_ITEMS.map(({ id }) => id)).toEqual(['general', 'billing', 'chat-keys']) expect(ORGANIZATION_SETTINGS_ITEMS.map(({ id }) => id)).toEqual([ @@ -145,6 +147,15 @@ describe('settings navigation boundaries', () => { expect(organizationSso?.docsLink).toBe(unifiedSso?.docsLink) }) + it('requires effective superuser mode for newsletters', () => { + const newsletters = buildUnifiedSettingsNavigation().find(({ id }) => id === 'newsletters') + + expect(newsletters).toMatchObject({ + requiresAdminRole: true, + requiresSuperUser: true, + }) + }) + it('keeps scope-specific labels only where the surface genuinely differs', () => { const organizationMembers = ORGANIZATION_SETTINGS_ITEMS.find(({ id }) => id === 'members') const unifiedOrganization = buildUnifiedSettingsNavigation().find( diff --git a/apps/sim/components/settings/navigation.ts b/apps/sim/components/settings/navigation.ts index 08f12d531b5..173cd91a00c 100644 --- a/apps/sim/components/settings/navigation.ts +++ b/apps/sim/components/settings/navigation.ts @@ -40,7 +40,13 @@ import { export type SettingsPlane = 'account' | 'organization' | 'selfhost' | 'workspace' -export type AccountSettingsSection = 'general' | 'billing' | 'api-keys' | 'admin' | 'mothership' +export type AccountSettingsSection = + | 'general' + | 'billing' + | 'api-keys' + | 'admin' + | 'mothership' + | 'newsletters' /** * Settings a self-hoster needs from the managed service: their profile, what @@ -115,6 +121,7 @@ export type UnifiedSettingsSection = | 'data-retention' | 'data-drains' | 'mothership' + | 'newsletters' | 'recently-deleted' export type UnifiedNavigationSection = @@ -753,6 +760,20 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] account: { id: 'mothership', group: 'platform', order: 5 }, }, }, + { + label: 'Newsletters', + icon: Send, + unified: { + id: 'newsletters', + description: 'Build newsletter audience snapshots for Resend.', + group: 'superuser', + requiresSuperUser: true, + requiresAdminRole: true, + }, + planes: { + account: { id: 'newsletters', group: 'platform', order: 6 }, + }, + }, ] export function buildUnifiedSettingsNavigation(): UnifiedSettingsNavigationItem[] { diff --git a/apps/sim/components/settings/standalone-settings-shell.tsx b/apps/sim/components/settings/standalone-settings-shell.tsx index c7d45633788..587d22421f8 100644 --- a/apps/sim/components/settings/standalone-settings-shell.tsx +++ b/apps/sim/components/settings/standalone-settings-shell.tsx @@ -34,6 +34,7 @@ interface StandaloneSettingsShellBaseProps { interface AccountSettingsShellProps extends StandaloneSettingsShellBaseProps { plane: 'account' isSuperUser?: boolean + isEffectiveSuperUser?: boolean } interface SelfHostSettingsShellProps extends StandaloneSettingsShellBaseProps { @@ -59,11 +60,13 @@ export function StandaloneSettingsShell(props: StandaloneSettingsShellProps) { const hasEnterprisePlan = plane === 'organization' ? props.hasEnterprisePlan : false const isOrganizationAdmin = plane === 'organization' ? props.isOrganizationAdmin : false const isSuperUser = plane === 'account' ? (props.isSuperUser ?? false) : false + const isEffectiveSuperUser = plane === 'account' ? (props.isEffectiveSuperUser ?? false) : false const organizationFeatures = getOrganizationSettingsFeatures(hasEnterprisePlan) const accountItems = ACCOUNT_SETTINGS_ITEMS.filter((item) => { if (item.id === 'billing' && !isBillingEnabled) return false if ((item.id === 'admin' || item.id === 'mothership') && !isSuperUser) return false + if (item.id === 'newsletters' && !isEffectiveSuperUser) return false return true }) const organizationItems = ORGANIZATION_SETTINGS_ITEMS.filter( diff --git a/apps/sim/hooks/queries/newsletters.ts b/apps/sim/hooks/queries/newsletters.ts new file mode 100644 index 00000000000..8d5c7175a0c --- /dev/null +++ b/apps/sim/hooks/queries/newsletters.ts @@ -0,0 +1,98 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { requestJson } from '@/lib/api/client/request' +import { + type CreateNewsletterRunBody, + createNewsletterRunContract, + finalizeNewsletterRunContract, + getNewsletterRunContract, + getNewsletterRunJobContract, + listNewsletterRunsContract, + pushNewsletterRunToResendContract, +} from '@/lib/api/contracts/newsletters' + +export const NEWSLETTER_RUN_LIST_STALE_TIME = 30 * 1000 +export const NEWSLETTER_RUN_DETAIL_STALE_TIME = 10 * 1000 +export const NEWSLETTER_JOB_STALE_TIME = 3 * 1000 + +export const newsletterKeys = { + all: ['newsletters'] as const, + lists: () => [...newsletterKeys.all, 'list'] as const, + list: () => [...newsletterKeys.lists()] as const, + details: () => [...newsletterKeys.all, 'detail'] as const, + detail: (id?: string) => [...newsletterKeys.details(), id ?? ''] as const, + jobs: () => [...newsletterKeys.all, 'job'] as const, + job: (id?: string) => [...newsletterKeys.jobs(), id ?? ''] as const, +} + +export function useNewsletterRuns() { + return useQuery({ + queryKey: newsletterKeys.list(), + queryFn: ({ signal }) => + requestJson(listNewsletterRunsContract, { query: { limit: 25, offset: 0 }, signal }), + staleTime: NEWSLETTER_RUN_LIST_STALE_TIME, + refetchInterval: (query) => + query.state.data?.runs.some((run) => run.status === 'pushing') + ? NEWSLETTER_JOB_STALE_TIME + : false, + }) +} + +export function useNewsletterRun(id?: string) { + return useQuery({ + queryKey: newsletterKeys.detail(id), + queryFn: ({ signal }) => + requestJson(getNewsletterRunContract, { params: { id: id as string }, signal }), + enabled: Boolean(id), + staleTime: NEWSLETTER_RUN_DETAIL_STALE_TIME, + }) +} + +export function useNewsletterJob(id?: string, enabled = false) { + return useQuery({ + queryKey: newsletterKeys.job(id), + queryFn: ({ signal }) => + requestJson(getNewsletterRunJobContract, { params: { id: id as string }, signal }), + enabled: Boolean(id) && enabled, + refetchInterval: (query) => { + const status = query.state.data?.job?.status + return enabled && (status === 'pending' || status === 'processing') + ? NEWSLETTER_JOB_STALE_TIME + : false + }, + staleTime: NEWSLETTER_JOB_STALE_TIME, + }) +} + +export function useCreateNewsletterRun() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (body: CreateNewsletterRunBody) => + requestJson(createNewsletterRunContract, { body }), + onSettled: () => { + queryClient.invalidateQueries({ queryKey: newsletterKeys.lists() }) + }, + }) +} + +export function useFinalizeNewsletterRun() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (id: string) => requestJson(finalizeNewsletterRunContract, { params: { id } }), + onSettled: (_data, _error, id) => { + queryClient.invalidateQueries({ queryKey: newsletterKeys.lists() }) + queryClient.invalidateQueries({ queryKey: newsletterKeys.detail(id) }) + }, + }) +} + +export function usePushNewsletterRunToResend() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (id: string) => requestJson(pushNewsletterRunToResendContract, { params: { id } }), + onSettled: (_data, _error, id) => { + queryClient.invalidateQueries({ queryKey: newsletterKeys.lists() }) + queryClient.invalidateQueries({ queryKey: newsletterKeys.detail(id) }) + queryClient.invalidateQueries({ queryKey: newsletterKeys.job(id) }) + }, + }) +} diff --git a/apps/sim/lib/api/contracts/newsletters.ts b/apps/sim/lib/api/contracts/newsletters.ts new file mode 100644 index 00000000000..f187301a26e --- /dev/null +++ b/apps/sim/lib/api/contracts/newsletters.ts @@ -0,0 +1,211 @@ +import { z } from 'zod' +import { defineRouteContract } from '@/lib/api/contracts/types' + +const lastQueryValue = (value: unknown) => (Array.isArray(value) ? value.at(-1) : value) + +const paginationValueSchema = (defaultValue: number, maxValue: number) => + z.preprocess(lastQueryValue, z.union([z.string(), z.number()]).optional()).transform((value) => { + const parsed = + typeof value === 'number' + ? value + : typeof value === 'string' + ? Number.parseInt(value, 10) + : Number.NaN + + if (!Number.isFinite(parsed) || !Number.isInteger(parsed) || parsed < 0) return defaultValue + return Math.min(parsed, maxValue) + }) + +export const newsletterRunIdParamsSchema = z.object({ + id: z.string({ error: 'Newsletter run id is required' }).min(1, 'Newsletter run id is required'), +}) + +export const newsletterRunStatusSchema = z.enum([ + 'draft', + 'finalizing', + 'finalized', + 'oversized', + 'pushing', + 'pushed', + 'failed', +]) + +export const newsletterRecipientSyncStatusSchema = z.enum([ + 'pending', + 'created', + 'updated', + 'segment_added', + 'excluded', + 'failed', +]) + +export const newsletterTargetingCriteriaSchema = z.discriminatedUnion('type', [ + z.object({ + type: z.literal('everyone'), + }), + z.object({ + type: z.literal('integration_users'), + integration: z.string().min(1), + timeWindowDays: z.number().int().min(1).max(3650).nullable(), + }), + z.object({ + type: z.literal('chat_mentions'), + term: z.string().min(1).max(80), + timeWindowDays: z.number().int().min(1).max(365), + }), + z.object({ + type: z.literal('recently_active'), + timeWindowDays: z.number().int().min(1).max(3650), + }), +]) + +export const newsletterRecipientSampleSchema = z.object({ + userId: z.string(), + email: z.string(), + name: z.string().nullable(), + reason: z.string(), +}) + +export const newsletterCountsSchema = z.object({ + totalMatched: z.number(), + excludedBanned: z.number(), + excludedUnverified: z.number(), + excludedUnsubscribed: z.number(), + excludedSuppressed: z.number(), + finalRecipientCount: z.number(), +}) + +export const newsletterRunSchema = z.object({ + id: z.string(), + name: z.string(), + prompt: z.string(), + criteria: newsletterTargetingCriteriaSchema, + status: newsletterRunStatusSchema, + counts: newsletterCountsSchema, + sampleRecipients: z.array(newsletterRecipientSampleSchema), + resendSegmentId: z.string().nullable(), + resendSegmentName: z.string().nullable(), + resendSyncedAt: z.string().nullable(), + resendSyncJobId: z.string().nullable(), + error: z.string().nullable(), + finalizedAt: z.string().nullable(), + createdAt: z.string(), + updatedAt: z.string(), +}) + +export const createNewsletterRunBodySchema = z.object({ + name: z.string({ error: 'Name is required' }).min(1, 'Name is required').max(120), + prompt: z.string({ error: 'Prompt is required' }).min(1, 'Prompt is required').max(1000), +}) + +export const listNewsletterRunsQuerySchema = z.object({ + limit: paginationValueSchema(25, 100), + offset: paginationValueSchema(0, 100000), +}) + +export const listNewsletterRunsResponseSchema = z.object({ + runs: z.array(newsletterRunSchema), + total: z.number(), +}) + +export const newsletterRunResponseSchema = z.object({ + run: newsletterRunSchema, +}) + +export const pushNewsletterRunResponseSchema = z.object({ + run: newsletterRunSchema, + jobId: z.string(), +}) + +export const newsletterJobResponseSchema = z.object({ + job: z + .object({ + id: z.string(), + status: z.enum(['pending', 'processing', 'completed', 'failed']), + attempts: z.number(), + maxAttempts: z.number(), + error: z.string().nullable(), + createdAt: z.string(), + startedAt: z.string().nullable(), + completedAt: z.string().nullable(), + }) + .nullable(), +}) + +export const listNewsletterRunsContract = defineRouteContract({ + method: 'GET', + path: '/api/superuser/newsletters/runs', + query: listNewsletterRunsQuerySchema, + response: { + mode: 'json', + schema: listNewsletterRunsResponseSchema, + }, +}) + +export const createNewsletterRunContract = defineRouteContract({ + method: 'POST', + path: '/api/superuser/newsletters/runs', + body: createNewsletterRunBodySchema, + response: { + mode: 'json', + schema: newsletterRunResponseSchema, + }, +}) + +export const getNewsletterRunContract = defineRouteContract({ + method: 'GET', + path: '/api/superuser/newsletters/runs/[id]', + params: newsletterRunIdParamsSchema, + response: { + mode: 'json', + schema: newsletterRunResponseSchema, + }, +}) + +export const finalizeNewsletterRunContract = defineRouteContract({ + method: 'POST', + path: '/api/superuser/newsletters/runs/[id]/finalize', + params: newsletterRunIdParamsSchema, + response: { + mode: 'json', + schema: newsletterRunResponseSchema, + }, +}) + +export const pushNewsletterRunToResendContract = defineRouteContract({ + method: 'POST', + path: '/api/superuser/newsletters/runs/[id]/push-resend', + params: newsletterRunIdParamsSchema, + response: { + mode: 'json', + schema: pushNewsletterRunResponseSchema, + }, +}) + +export const getNewsletterRunJobContract = defineRouteContract({ + method: 'GET', + path: '/api/superuser/newsletters/runs/[id]/job', + params: newsletterRunIdParamsSchema, + response: { + mode: 'json', + schema: newsletterJobResponseSchema, + }, +}) + +export const exportNewsletterRunCsvContract = defineRouteContract({ + method: 'GET', + path: '/api/superuser/newsletters/runs/[id]/export.csv', + params: newsletterRunIdParamsSchema, + response: { + mode: 'text', + }, +}) + +export type NewsletterTargetingCriteria = z.output +export type NewsletterRecipientSyncStatus = z.output +export type NewsletterRun = z.output +export type CreateNewsletterRunBody = z.input +export type ListNewsletterRunsResponse = z.output +export type NewsletterRunResponse = z.output +export type PushNewsletterRunResponse = z.output +export type NewsletterJobResponse = z.output diff --git a/apps/sim/lib/core/async-jobs/backends/database.test.ts b/apps/sim/lib/core/async-jobs/backends/database.test.ts index 1d7207031bc..d09700eb6b6 100644 --- a/apps/sim/lib/core/async-jobs/backends/database.test.ts +++ b/apps/sim/lib/core/async-jobs/backends/database.test.ts @@ -9,10 +9,16 @@ vi.mock('@sim/db', () => ({ asyncJobs: { attempts: 'attempts', id: 'id', + metadata: 'metadata', + status: 'status', }, db: dbChainMock.db, })) +vi.mock('@sim/utils/id', () => ({ + generateShortId: vi.fn(() => 'inline-claim-token'), +})) + import { DatabaseJobQueue } from '@/lib/core/async-jobs/backends/database' import { AsyncJobEnqueueError } from '@/lib/core/async-jobs/types' @@ -38,7 +44,9 @@ describe('DatabaseJobQueue enqueue', () => { }) it('returns the deterministic job ID when verification finds an accepted insert', async () => { - dbChainMockFns.onConflictDoNothing.mockRejectedValueOnce(new Error('connection lost')) + dbChainMockFns.onConflictDoNothing.mockImplementationOnce(() => { + throw new Error('connection lost') + }) dbChainMockFns.limit.mockResolvedValueOnce([EXISTING_JOB]) const queue = new DatabaseJobQueue() @@ -48,7 +56,9 @@ describe('DatabaseJobQueue enqueue', () => { }) it('proves non-acceptance when verification succeeds without finding the job', async () => { - dbChainMockFns.onConflictDoNothing.mockRejectedValueOnce(new Error('insert rejected')) + dbChainMockFns.onConflictDoNothing.mockImplementationOnce(() => { + throw new Error('insert rejected') + }) dbChainMockFns.limit.mockResolvedValueOnce([]) const queue = new DatabaseJobQueue() @@ -64,7 +74,9 @@ describe('DatabaseJobQueue enqueue', () => { }) it('reports ambiguous acceptance when insert verification also fails', async () => { - dbChainMockFns.onConflictDoNothing.mockRejectedValueOnce(new Error('connection lost')) + dbChainMockFns.onConflictDoNothing.mockImplementationOnce(() => { + throw new Error('connection lost') + }) dbChainMockFns.limit.mockRejectedValueOnce(new Error('database unavailable')) const queue = new DatabaseJobQueue() @@ -78,6 +90,191 @@ describe('DatabaseJobQueue enqueue', () => { retryable: true, }) }) + + it('starts the inline runner with the persisted job payload', async () => { + dbChainMockFns.returning.mockResolvedValueOnce([ + { payload: { executionId: 'persisted-execution' } }, + ]) + const runner = vi.fn().mockResolvedValue(undefined) + const queue = new DatabaseJobQueue() + + await queue.enqueue( + 'workflow-execution', + { executionId: 'execution-1' }, + { jobId: 'workflow:1', runner } + ) + await sleep(1) + + expect(runner).toHaveBeenCalledTimes(1) + expect(runner).toHaveBeenCalledWith( + { executionId: 'persisted-execution' }, + expect.any(AbortSignal) + ) + }) + + it('does not restart the inline runner for a duplicate job id', async () => { + dbChainMockFns.returning.mockResolvedValueOnce([]) + const runner = vi.fn().mockResolvedValue(undefined) + const queue = new DatabaseJobQueue() + + await queue.enqueue( + 'workflow-execution', + { executionId: 'execution-1' }, + { jobId: 'workflow:1', runner } + ) + await sleep(1) + + expect(runner).not.toHaveBeenCalled() + }) + + it('claims and runs a verified ambiguously accepted job', async () => { + dbChainMockFns.onConflictDoNothing.mockImplementationOnce(() => { + throw new Error('connection lost') + }) + dbChainMockFns.limit.mockResolvedValueOnce([EXISTING_JOB]) + dbChainMockFns.returning.mockResolvedValueOnce([{ payload: EXISTING_JOB.payload }]) + const runner = vi.fn().mockResolvedValue(undefined) + const queue = new DatabaseJobQueue() + + await queue.enqueue( + 'workflow-execution', + { executionId: 'execution-1' }, + { jobId: 'workflow:1', runner } + ) + await sleep(1) + + expect(runner).toHaveBeenCalledTimes(1) + }) + + it('runs after verifying a claim whose committed response was lost', async () => { + dbChainMockFns.returning.mockRejectedValueOnce(new Error('claim response lost')) + dbChainMockFns.limit.mockResolvedValueOnce([ + { + status: 'processing', + metadata: { __sim: { inlineClaim: { token: 'inline-claim-token' } } }, + payload: EXISTING_JOB.payload, + updatedAt: new Date(), + }, + ]) + const runner = vi.fn().mockResolvedValue(undefined) + const queue = new DatabaseJobQueue() + + await queue.enqueue( + 'workflow-execution', + { executionId: 'execution-1' }, + { jobId: 'workflow:1', runner } + ) + await sleep(1) + + expect(runner).toHaveBeenCalledTimes(1) + }) + + it('leaves a job claimed by another inline runner unchanged', async () => { + dbChainMockFns.returning.mockRejectedValueOnce(new Error('claim failed')) + dbChainMockFns.limit.mockResolvedValueOnce([ + { + status: 'processing', + metadata: { __sim: { inlineClaim: { token: 'another-runner' } } }, + payload: EXISTING_JOB.payload, + updatedAt: new Date(), + }, + ]) + const runner = vi.fn().mockResolvedValue(undefined) + const queue = new DatabaseJobQueue() + + await queue.enqueue( + 'workflow-execution', + { executionId: 'execution-1' }, + { jobId: 'workflow:1', runner } + ) + await sleep(1) + + expect(runner).not.toHaveBeenCalled() + expect(dbChainMockFns.set).toHaveBeenCalledTimes(1) + }) + + it('takes over an expired processing claim', async () => { + dbChainMockFns.returning + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([{ payload: EXISTING_JOB.payload }]) + dbChainMockFns.limit.mockResolvedValueOnce([ + { + status: 'processing', + metadata: { __sim: { inlineClaim: { token: 'expired-runner' } } }, + payload: EXISTING_JOB.payload, + updatedAt: new Date(0), + }, + ]) + const runner = vi.fn().mockResolvedValue(undefined) + const queue = new DatabaseJobQueue() + + await queue.enqueue( + 'workflow-execution', + { executionId: 'different-caller-payload' }, + { jobId: 'workflow:1', runner } + ) + await sleep(1) + + expect(runner).toHaveBeenCalledWith(EXISTING_JOB.payload, expect.any(AbortSignal)) + }) + + it('aborts the runner when a heartbeat discovers that the claim was lost', async () => { + vi.useFakeTimers() + try { + dbChainMockFns.returning + .mockResolvedValueOnce([{ payload: EXISTING_JOB.payload }]) + .mockResolvedValueOnce([]) + let runnerSignal: AbortSignal | undefined + const runner = vi.fn( + async (_payload: unknown, signal: AbortSignal) => + new Promise((resolve) => { + runnerSignal = signal + signal.addEventListener('abort', () => resolve(), { once: true }) + }) + ) + const queue = new DatabaseJobQueue() + + await queue.enqueue( + 'workflow-execution', + { executionId: 'execution-1' }, + { jobId: 'workflow:1', runner } + ) + await vi.advanceTimersByTimeAsync(30_000) + + expect(runnerSignal?.aborted).toBe(true) + } finally { + vi.useRealTimers() + } + }) + + it('aborts the runner when a heartbeat query hangs past the claim lease', async () => { + vi.useFakeTimers() + try { + dbChainMockFns.returning + .mockResolvedValueOnce([{ payload: EXISTING_JOB.payload }]) + .mockImplementationOnce(() => new Promise(() => {})) + let runnerSignal: AbortSignal | undefined + const runner = vi.fn( + async (_payload: unknown, signal: AbortSignal) => + new Promise((resolve) => { + runnerSignal = signal + signal.addEventListener('abort', () => resolve(), { once: true }) + }) + ) + const queue = new DatabaseJobQueue() + + await queue.enqueue( + 'workflow-execution', + { executionId: 'execution-1' }, + { jobId: 'workflow:1', runner } + ) + await vi.advanceTimersByTimeAsync(120_000) + + expect(runnerSignal?.aborted).toBe(true) + } finally { + vi.useRealTimers() + } + }) }) describe('DatabaseJobQueue batchEnqueueAndWait', () => { diff --git a/apps/sim/lib/core/async-jobs/backends/database.ts b/apps/sim/lib/core/async-jobs/backends/database.ts index 45feb6ed412..b4029223f59 100644 --- a/apps/sim/lib/core/async-jobs/backends/database.ts +++ b/apps/sim/lib/core/async-jobs/backends/database.ts @@ -2,7 +2,7 @@ import { asyncJobs, db } from '@sim/db' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { generateShortId } from '@sim/utils/id' -import { eq, sql } from 'drizzle-orm' +import { and, eq, lte, or, sql } from 'drizzle-orm' import { AsyncJobEnqueueError, type EnqueueOptions, @@ -15,9 +15,26 @@ import { } from '@/lib/core/async-jobs/types' const logger = createLogger('DatabaseJobQueue') +const INLINE_CLAIM_ATTEMPTS = 3 +const INLINE_CLAIM_LEASE_MS = 2 * 60 * 1000 +const INLINE_CLAIM_HEARTBEAT_MS = 30 * 1000 type AsyncJobRow = typeof asyncJobs.$inferSelect type Runner = NonNullable +type InlineClaimResult = + | { state: 'owned'; payload: TPayload } + | { state: 'active' } + | { state: 'settled' } + +function getInlineClaimToken(metadata: unknown): string | undefined { + if (!metadata || typeof metadata !== 'object') return undefined + const internal = (metadata as Record).__sim + if (!internal || typeof internal !== 'object') return undefined + const claim = (internal as Record).inlineClaim + if (!claim || typeof claim !== 'object') return undefined + const token = (claim as Record).token + return typeof token === 'string' ? token : undefined +} function rowToJob(row: AsyncJobRow): Job { return { @@ -139,10 +156,9 @@ export class DatabaseJobQueue implements JobQueueBackend { logger.debug('Enqueued job', { jobId, type }) if (options?.runner) { - this.runInline( + await this.startInline( type, jobId, - payload, options.runner, options.concurrencyKey, options.concurrencyLimit @@ -173,19 +189,18 @@ export class DatabaseJobQueue implements JobQueueBackend { logger.debug('Batch-enqueued jobs', { count: rows.length, type }) - for (let i = 0; i < items.length; i++) { - const { payload, options } = items[i] - if (options?.runner) { - this.runInline( + await Promise.all( + items.map(({ options }, index) => { + if (!options?.runner) return Promise.resolve() + return this.startInline( type, - rows[i].id, - payload, + rows[index].id, options.runner, options.concurrencyKey, options.concurrencyLimit ) - } - } + }) + ) return rows.map((r) => r.id) } @@ -298,6 +313,116 @@ export class DatabaseJobQueue implements JobQueueBackend { logger.debug('Marked job as failed', { jobId }) } + private async claimInlineJob( + jobId: string, + claimToken: string + ): Promise> { + let lastError: Error | undefined + + for (let attempt = 0; attempt < INLINE_CLAIM_ATTEMPTS; attempt += 1) { + try { + const now = new Date() + const leaseCutoff = new Date(now.getTime() - INLINE_CLAIM_LEASE_MS) + const claimed = await db + .update(asyncJobs) + .set({ + status: JOB_STATUS.PROCESSING, + startedAt: now, + attempts: sql`${asyncJobs.attempts} + 1`, + metadata: sql`coalesce(${asyncJobs.metadata}, '{}'::jsonb) || jsonb_build_object( + '__sim', + coalesce(${asyncJobs.metadata} -> '__sim', '{}'::jsonb) || jsonb_build_object( + 'inlineClaim', + jsonb_build_object('token', ${claimToken}, 'claimedAt', ${now.toISOString()}) + ) + )`, + updatedAt: now, + }) + .where( + and( + eq(asyncJobs.id, jobId), + or( + eq(asyncJobs.status, JOB_STATUS.PENDING), + and( + eq(asyncJobs.status, JOB_STATUS.PROCESSING), + lte(asyncJobs.updatedAt, leaseCutoff) + ) + ) + ) + ) + .returning({ payload: asyncJobs.payload }) + if (claimed.length > 0) { + return { state: 'owned', payload: claimed[0].payload as TPayload } + } + } catch (error) { + lastError = toError(error) + } + + try { + const [row] = await db + .select({ + metadata: asyncJobs.metadata, + payload: asyncJobs.payload, + status: asyncJobs.status, + updatedAt: asyncJobs.updatedAt, + }) + .from(asyncJobs) + .where(eq(asyncJobs.id, jobId)) + .limit(1) + + if (!row) return { state: 'settled' } + if (row.status === JOB_STATUS.PENDING) continue + if (row.status !== JOB_STATUS.PROCESSING) { + return { state: 'settled' } + } + + if (getInlineClaimToken(row.metadata) === claimToken) { + return { state: 'owned', payload: row.payload as TPayload } + } + if (row.updatedAt.getTime() <= Date.now() - INLINE_CLAIM_LEASE_MS) continue + return { state: 'active' } + } catch (error) { + lastError = toError(error) + } + } + + throw lastError ?? new Error(`Unable to claim inline job ${jobId}`) + } + + private claimFence(jobId: string, claimToken: string) { + return and( + eq(asyncJobs.id, jobId), + eq(asyncJobs.status, JOB_STATUS.PROCESSING), + sql`${asyncJobs.metadata} #>> '{__sim,inlineClaim,token}' = ${claimToken}` + ) + } + + private async completeInlineJob(jobId: string, claimToken: string): Promise { + const now = new Date() + await db + .update(asyncJobs) + .set({ + status: JOB_STATUS.COMPLETED, + completedAt: now, + output: null, + updatedAt: now, + }) + .where(this.claimFence(jobId, claimToken)) + } + + private async failInlineJob(jobId: string, claimToken: string, error: string): Promise { + const now = new Date() + await db + .update(asyncJobs) + .set({ + status: JOB_STATUS.FAILED, + completedAt: now, + error, + updatedAt: now, + }) + .where(this.claimFence(jobId, claimToken)) + } + async cancelJob(jobId: string): Promise { // Abort any in-process inline execution first so the running workflow // observes the signal and stops mid-flight. Then mark the row failed so @@ -333,39 +458,98 @@ export class DatabaseJobQueue implements JobQueueBackend { } /** - * Fire-and-forget IIFE that owns the lifecycle for an inline job: registers - * the abort controller (so `cancelJob` can interrupt mid-flight), acquires - * a concurrency slot if `concurrencyKey` is set, drives - * `startJob → runner → completeJob | markJobFailed`. + * Claims the persisted job before returning, then owns its inline lifecycle. + * The detached runner renews the claim while queued or running and all + * terminal writes are fenced by the claim token. */ - private runInline( + private async startInline( type: JobType, jobId: string, - payload: TPayload, runner: Runner, concurrencyKey?: string, concurrencyLimit?: number - ): void { + ): Promise { const abortController = new AbortController() + const claimToken = generateShortId(20) + let claim: InlineClaimResult + + try { + claim = await this.claimInlineJob(jobId, claimToken) + } catch (error) { + throw new AsyncJobEnqueueError( + `Unable to establish ownership of database job: ${toError(error).message}`, + { + acceptance: 'unknown', + retryable: true, + cause: error, + } + ) + } + + if (claim.state === 'settled') return + if (claim.state === 'active') return + + inlineAbortControllers.get(jobId)?.abort('Inline job claim superseded') inlineAbortControllers.set(jobId, abortController) + let leaseValidUntil = Date.now() + INLINE_CLAIM_LEASE_MS + let heartbeatInFlight = false + const heartbeat = setInterval(() => { + if (Date.now() >= leaseValidUntil) { + abortController.abort('Inline job claim expired') + return + } + if (heartbeatInFlight) return + heartbeatInFlight = true + void db + .update(asyncJobs) + .set({ updatedAt: new Date() }) + .where(this.claimFence(jobId, claimToken)) + .returning({ id: asyncJobs.id }) + .then((renewed) => { + if (renewed.length === 0) { + abortController.abort('Inline job claim lost') + return + } + leaseValidUntil = Date.now() + INLINE_CLAIM_LEASE_MS + }) + .catch((error) => { + logger.warn(`[${type}] Failed to renew inline job ${jobId} claim`, { + error: toError(error).message, + }) + if (Date.now() >= leaseValidUntil) { + abortController.abort('Inline job claim expired') + } + }) + .finally(() => { + heartbeatInFlight = false + }) + }, INLINE_CLAIM_HEARTBEAT_MS) + heartbeat.unref() + void (async () => { if (concurrencyKey && concurrencyLimit && concurrencyLimit > 0) { await acquireSlot(concurrencyKey, concurrencyLimit) } try { - await this.startJob(jobId) - await runner(payload, abortController.signal) - await this.completeJob(jobId, null) - } catch (err) { - const message = toError(err).message - logger.error(`[${type}] Inline job ${jobId} failed`, { error: message }) try { - await this.markJobFailed(jobId, message) - } catch (markErr) { - logger.error(`[${type}] Failed to mark job ${jobId} as failed`, { markErr }) + await runner(claim.payload, abortController.signal) + await this.completeInlineJob(jobId, claimToken) + } catch (error) { + const message = toError(error).message + logger.error(`[${type}] Inline job ${jobId} failed`, { error: message }) + try { + await this.failInlineJob(jobId, claimToken, message) + } catch (markError) { + logger.error(`[${type}] Failed to mark inline job ${jobId} as failed`, { + error: toError(markError).message, + }) + } } } finally { - inlineAbortControllers.delete(jobId) + clearInterval(heartbeat) + if (inlineAbortControllers.get(jobId) === abortController) { + inlineAbortControllers.delete(jobId) + } if (concurrencyKey && concurrencyLimit && concurrencyLimit > 0) { releaseSlot(concurrencyKey) } diff --git a/apps/sim/lib/core/async-jobs/backends/trigger-dev.ts b/apps/sim/lib/core/async-jobs/backends/trigger-dev.ts index 12f9f15bc88..fa83e2d15b6 100644 --- a/apps/sim/lib/core/async-jobs/backends/trigger-dev.ts +++ b/apps/sim/lib/core/async-jobs/backends/trigger-dev.ts @@ -46,6 +46,7 @@ const JOB_TYPE_TO_TASK_ID: Record = { 'cleanup-soft-deletes': 'cleanup-soft-deletes', 'cleanup-tasks': 'cleanup-tasks', 'run-data-drain': 'run-data-drain', + 'newsletter-resend-sync': 'newsletter-resend-sync', } /** diff --git a/apps/sim/lib/core/async-jobs/types.ts b/apps/sim/lib/core/async-jobs/types.ts index 11b74f13287..208ff42931c 100644 --- a/apps/sim/lib/core/async-jobs/types.ts +++ b/apps/sim/lib/core/async-jobs/types.ts @@ -31,6 +31,7 @@ export type JobType = | 'cleanup-soft-deletes' | 'cleanup-tasks' | 'run-data-drain' + | 'newsletter-resend-sync' export type AsyncExecutionCorrelationSource = 'workflow' | 'schedule' | 'webhook' diff --git a/apps/sim/lib/newsletters/auth.ts b/apps/sim/lib/newsletters/auth.ts new file mode 100644 index 00000000000..267d6e5475e --- /dev/null +++ b/apps/sim/lib/newsletters/auth.ts @@ -0,0 +1,37 @@ +import { createLogger } from '@sim/logger' +import { NextResponse } from 'next/server' +import { getSession } from '@/lib/auth' +import { verifyEffectiveSuperUser } from '@/lib/permissions/super-user' + +const logger = createLogger('NewsletterSuperuserAuth') + +export async function validateNewsletterSuperuser() { + const session = await getSession() + if (!session?.user?.id) { + return { + success: false as const, + response: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + } + } + + const { effectiveSuperUser, isSuperUser, superUserModeEnabled } = await verifyEffectiveSuperUser( + session.user.id + ) + + if (!effectiveSuperUser) { + logger.warn('Non-effective-superuser attempted to access newsletter admin endpoint', { + userId: session.user.id, + isSuperUser, + superUserModeEnabled, + }) + return { + success: false as const, + response: NextResponse.json( + { error: 'Forbidden: Superuser access required' }, + { status: 403 } + ), + } + } + + return { success: true as const, userId: session.user.id } +} diff --git a/apps/sim/lib/newsletters/push-resend.test.ts b/apps/sim/lib/newsletters/push-resend.test.ts new file mode 100644 index 00000000000..6d7f5361dad --- /dev/null +++ b/apps/sim/lib/newsletters/push-resend.test.ts @@ -0,0 +1,263 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + claimAttempt: vi.fn(), + countByStatus: vi.fn(), + createSegment: vi.fn(), + ensureProperties: vi.fn(), + getAsyncBackendType: vi.fn(), + getExcludedEmails: vi.fn(), + getJobQueue: vi.fn(), + getPendingRecipients: vi.fn(), + isAsyncJobEnqueueError: vi.fn(), + markFailed: vi.fn(), + markPushed: vi.fn(), + queueEnqueue: vi.fn(), + queueGetJob: vi.fn(), + requireAttempt: vi.fn(), + resetFailedRecipients: vi.fn(), + setJob: vi.fn(), + setSegment: vi.fn(), + updateRecipient: vi.fn(), +})) + +vi.mock('@/lib/core/async-jobs', () => ({ + getAsyncBackendType: mocks.getAsyncBackendType, + getJobQueue: mocks.getJobQueue, + isAsyncJobEnqueueError: mocks.isAsyncJobEnqueueError, + JOB_STATUS: { + COMPLETED: 'completed', + FAILED: 'failed', + }, +})) + +vi.mock('@/lib/newsletters/resend', () => ({ + createNewsletterSegment: mocks.createSegment, + createOrSegmentNewsletterContact: vi.fn(), + ensureNewsletterContactProperties: mocks.ensureProperties, + getResendExcludedEmails: mocks.getExcludedEmails, +})) + +vi.mock('@/lib/newsletters/runs', () => ({ + claimNewsletterRunResendAttempt: mocks.claimAttempt, + countNewsletterRecipientsByStatus: mocks.countByStatus, + getPendingNewsletterRecipients: mocks.getPendingRecipients, + markNewsletterRunPushed: mocks.markPushed, + markNewsletterRunPushFailed: mocks.markFailed, + requireNewsletterRunAttempt: mocks.requireAttempt, + resetFailedNewsletterRecipients: mocks.resetFailedRecipients, + setNewsletterRunResendJob: mocks.setJob, + setNewsletterRunResendSegment: mocks.setSegment, + updateRecipientSyncStatus: mocks.updateRecipient, +})) + +import { enqueueNewsletterResendSync, runNewsletterResendSync } from '@/lib/newsletters/push-resend' + +const run = { + id: 'run-1', + name: 'Launch', + status: 'pushing', + resendSegmentId: 'segment-1', + resendSegmentName: 'Segment 1', + resendSyncJobId: null, +} + +describe('newsletter Resend queueing', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.getAsyncBackendType.mockReturnValue('trigger-dev') + mocks.getJobQueue.mockResolvedValue({ + enqueue: mocks.queueEnqueue, + getJob: mocks.queueGetJob, + }) + mocks.claimAttempt.mockResolvedValue({ + attempt: 2, + jobId: null, + run, + shouldEnqueue: true, + }) + mocks.queueEnqueue.mockResolvedValue('trigger-run-123') + mocks.setJob.mockResolvedValue({ ...run, resendSyncJobId: 'trigger-run-123' }) + mocks.isAsyncJobEnqueueError.mockReturnValue(false) + }) + + it('stores the provider job id returned by the queue', async () => { + const result = await enqueueNewsletterResendSync('run-1', 'admin-1') + + expect(mocks.queueEnqueue).toHaveBeenCalledWith( + 'newsletter-resend-sync', + { runId: 'run-1', attempt: 2, requestedById: 'admin-1' }, + expect.objectContaining({ jobId: 'newsletter_resend_run-1_2', maxAttempts: 3 }) + ) + expect(mocks.setJob).toHaveBeenCalledWith('run-1', 2, 'trigger-run-123') + expect(result.jobId).toBe('trigger-run-123') + }) + + it('leaves an uncertain enqueue attempt resumable', async () => { + const error = new Error('response lost') + mocks.queueEnqueue.mockRejectedValue(error) + mocks.isAsyncJobEnqueueError.mockReturnValue(true) + + await expect(enqueueNewsletterResendSync('run-1', 'admin-1')).rejects.toThrow('response lost') + expect(mocks.markFailed).not.toHaveBeenCalled() + }) + + it('does not mark an accepted job failed when provider-id persistence fails', async () => { + mocks.setJob.mockRejectedValueOnce(new Error('database unavailable')) + + await expect(enqueueNewsletterResendSync('run-1', 'admin-1')).rejects.toThrow( + 'database unavailable' + ) + expect(mocks.queueEnqueue).toHaveBeenCalledTimes(1) + expect(mocks.markFailed).not.toHaveBeenCalled() + }) + + it('re-enters a persisted database job so an expired claim can be recovered', async () => { + mocks.getAsyncBackendType.mockReturnValue('database') + mocks.claimAttempt.mockResolvedValue({ + attempt: 2, + jobId: 'newsletter_resend_run-1_2', + run: { ...run, resendSyncJobId: 'newsletter_resend_run-1_2' }, + shouldEnqueue: false, + }) + mocks.queueGetJob.mockResolvedValue({ + id: 'newsletter_resend_run-1_2', + status: 'processing', + }) + mocks.queueEnqueue.mockResolvedValue('newsletter_resend_run-1_2') + mocks.setJob.mockResolvedValue({ + ...run, + resendSyncJobId: 'newsletter_resend_run-1_2', + }) + + await enqueueNewsletterResendSync('run-1', 'admin-1') + + expect(mocks.queueEnqueue).toHaveBeenCalledWith( + 'newsletter-resend-sync', + { runId: 'run-1', attempt: 2, requestedById: 'admin-1' }, + expect.objectContaining({ jobId: 'newsletter_resend_run-1_2' }) + ) + }) + + it('moves a newsletter run to failed when its persisted database job failed', async () => { + mocks.getAsyncBackendType.mockReturnValue('database') + mocks.claimAttempt.mockResolvedValue({ + attempt: 2, + jobId: 'newsletter_resend_run-1_2', + run, + shouldEnqueue: false, + }) + mocks.queueGetJob.mockResolvedValue({ + id: 'newsletter_resend_run-1_2', + status: 'failed', + error: 'worker stopped', + }) + + await expect(enqueueNewsletterResendSync('run-1', 'admin-1')).rejects.toThrow('worker stopped') + expect(mocks.markFailed).toHaveBeenCalledWith('run-1', 2, expect.any(Error)) + expect(mocks.queueEnqueue).not.toHaveBeenCalled() + }) + + it('resets failed recipients before a task retry', async () => { + mocks.requireAttempt.mockResolvedValue(run) + mocks.getExcludedEmails.mockResolvedValue(new Set()) + mocks.getPendingRecipients.mockResolvedValue([]) + mocks.countByStatus.mockResolvedValue({}) + + await runNewsletterResendSync({ + runId: 'run-1', + attempt: 2, + requestedById: 'admin-1', + }) + + expect(mocks.resetFailedRecipients).toHaveBeenCalledWith('run-1') + expect(mocks.markPushed).toHaveBeenCalledWith('run-1', 2, 'segment-1', 'Segment 1') + }) + + it('treats same-attempt worker re-entry after success as a no-op', async () => { + mocks.requireAttempt.mockResolvedValue({ ...run, status: 'pushed' }) + + await runNewsletterResendSync({ + runId: 'run-1', + attempt: 2, + requestedById: 'admin-1', + }) + + expect(mocks.resetFailedRecipients).not.toHaveBeenCalled() + expect(mocks.markFailed).not.toHaveBeenCalled() + }) + + it('does not fail the newsletter attempt when its database claim is aborted', async () => { + const controller = new AbortController() + controller.abort('claim lost') + + await expect( + runNewsletterResendSync( + { + runId: 'run-1', + attempt: 2, + requestedById: 'admin-1', + }, + controller.signal + ) + ).rejects.toBe('claim lost') + + expect(mocks.requireAttempt).not.toHaveBeenCalled() + expect(mocks.markFailed).not.toHaveBeenCalled() + }) + + it('does not persist a segment after ownership is lost during segment creation', async () => { + const controller = new AbortController() + mocks.requireAttempt.mockResolvedValue({ + ...run, + resendSegmentId: null, + resendSegmentName: null, + }) + mocks.createSegment.mockImplementationOnce(async () => { + controller.abort('claim lost') + return { id: 'segment-new', name: 'Segment new' } + }) + + await expect( + runNewsletterResendSync( + { + runId: 'run-1', + attempt: 2, + requestedById: 'admin-1', + }, + controller.signal + ) + ).rejects.toBe('claim lost') + + expect(mocks.setSegment).not.toHaveBeenCalled() + expect(mocks.markFailed).not.toHaveBeenCalled() + }) + + it('does not mark a run pushed after ownership is lost during status aggregation', async () => { + const controller = new AbortController() + mocks.requireAttempt.mockResolvedValue(run) + mocks.getExcludedEmails.mockResolvedValue(new Set()) + mocks.getPendingRecipients.mockResolvedValue([]) + mocks.countByStatus.mockImplementationOnce(async () => { + controller.abort('claim lost') + return {} + }) + + await expect( + runNewsletterResendSync( + { + runId: 'run-1', + attempt: 2, + requestedById: 'admin-1', + }, + controller.signal + ) + ).rejects.toBe('claim lost') + + expect(mocks.markPushed).not.toHaveBeenCalled() + expect(mocks.markFailed).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/newsletters/push-resend.ts b/apps/sim/lib/newsletters/push-resend.ts new file mode 100644 index 00000000000..c04070cebc4 --- /dev/null +++ b/apps/sim/lib/newsletters/push-resend.ts @@ -0,0 +1,229 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { generateShortId } from '@sim/utils/id' +import { z } from 'zod' +import { + getAsyncBackendType, + getJobQueue, + isAsyncJobEnqueueError, + JOB_STATUS, +} from '@/lib/core/async-jobs' +import { + createNewsletterSegment, + createOrSegmentNewsletterContact, + ensureNewsletterContactProperties, + getResendExcludedEmails, +} from '@/lib/newsletters/resend' +import { + claimNewsletterRunResendAttempt, + countNewsletterRecipientsByStatus, + getPendingNewsletterRecipients, + markNewsletterRunPushed, + markNewsletterRunPushFailed, + requireNewsletterRunAttempt, + resetFailedNewsletterRecipients, + setNewsletterRunResendJob, + setNewsletterRunResendSegment, + updateRecipientSyncStatus, +} from '@/lib/newsletters/runs' + +const logger = createLogger('NewsletterResendSync') +const NEWSLETTER_RESEND_BATCH_SIZE = 25 +const NEWSLETTER_RESEND_CONCURRENCY = 5 +export const NEWSLETTER_RESEND_SYNC_CONCURRENCY_LIMIT = 1 +export const NEWSLETTER_RESEND_SYNC_MAX_ATTEMPTS = 3 + +const newsletterResendSyncPayloadSchema = z.object({ + runId: z.string().min(1), + attempt: z.number().int().positive(), + requestedById: z.string().min(1), +}) + +export type NewsletterResendSyncPayload = z.output + +function segmentNameForRun(runName: string): string { + const date = new Date().toISOString().slice(0, 10) + return `Sim Newsletter - ${runName} - ${date} - ${generateShortId(8)}` +} + +async function runWithConcurrency( + items: T[], + limit: number, + worker: (item: T) => Promise, + signal?: AbortSignal +) { + for (let index = 0; index < items.length; index += limit) { + signal?.throwIfAborted() + await Promise.all(items.slice(index, index + limit).map(worker)) + } +} + +export async function runNewsletterResendSync( + payload: NewsletterResendSyncPayload, + signal?: AbortSignal +) { + const { runId, attempt } = newsletterResendSyncPayloadSchema.parse(payload) + + try { + signal?.throwIfAborted() + const run = await requireNewsletterRunAttempt(runId, attempt) + if (run.status === 'pushed') return + if (run.status !== 'finalized' && run.status !== 'pushing' && run.status !== 'failed') { + throw new Error('Newsletter run must be finalized before pushing to Resend') + } + await resetFailedNewsletterRecipients(runId) + + signal?.throwIfAborted() + const segment = + run.resendSegmentId && run.resendSegmentName + ? { id: run.resendSegmentId, name: run.resendSegmentName } + : await createNewsletterSegment(segmentNameForRun(run.name)) + + signal?.throwIfAborted() + if (!run.resendSegmentId) { + await setNewsletterRunResendSegment(runId, attempt, segment.id, segment.name) + } + + signal?.throwIfAborted() + await ensureNewsletterContactProperties() + signal?.throwIfAborted() + const suppressedEmails = await getResendExcludedEmails() + let processed = 0 + + while (true) { + signal?.throwIfAborted() + const recipients = await getPendingNewsletterRecipients(runId, NEWSLETTER_RESEND_BATCH_SIZE) + if (recipients.length === 0) break + + await runWithConcurrency( + recipients, + NEWSLETTER_RESEND_CONCURRENCY, + async (recipient) => { + signal?.throwIfAborted() + if ( + !recipient.currentUserEligible || + recipient.simUnsubscribed || + suppressedEmails.has(recipient.email.toLowerCase()) + ) { + await updateRecipientSyncStatus( + runId, + recipient.snapshotVersion, + recipient.email, + 'excluded' + ) + return + } + + try { + const result = await createOrSegmentNewsletterContact({ + email: recipient.email, + name: recipient.name, + userId: recipient.userId, + runId, + segmentId: segment.id, + }) + signal?.throwIfAborted() + await updateRecipientSyncStatus( + runId, + recipient.snapshotVersion, + recipient.email, + result.status, + { + contactId: result.contactId, + } + ) + } catch (error) { + signal?.throwIfAborted() + await updateRecipientSyncStatus( + runId, + recipient.snapshotVersion, + recipient.email, + 'failed', + { + error: getErrorMessage(error, 'Failed to sync recipient'), + } + ) + } + }, + signal + ) + + processed += recipients.length + logger.info('Processed newsletter Resend recipient batch', { + runId, + processed, + segmentId: segment.id, + }) + } + + signal?.throwIfAborted() + const statusCounts = await countNewsletterRecipientsByStatus(runId) + signal?.throwIfAborted() + const failed = statusCounts.failed ?? 0 + if (failed > 0) { + throw new Error(`${failed} recipients failed to sync to Resend`) + } + + signal?.throwIfAborted() + await markNewsletterRunPushed(runId, attempt, segment.id, segment.name) + } catch (error) { + if (signal?.aborted) throw error + await markNewsletterRunPushFailed(runId, attempt, error) + throw error + } +} + +export async function enqueueNewsletterResendSync(runId: string, requestedById: string) { + const claim = await claimNewsletterRunResendAttempt(runId) + const queue = await getJobQueue() + if (!claim.shouldEnqueue && claim.jobId) { + if (getAsyncBackendType() !== 'database') { + return { run: claim.run, jobId: claim.jobId } + } + + const persistedJob = await queue.getJob(claim.jobId) + if (persistedJob?.status === JOB_STATUS.COMPLETED) { + return { run: claim.run, jobId: claim.jobId } + } + if (persistedJob?.status === JOB_STATUS.FAILED) { + const error = new Error(persistedJob.error ?? 'Newsletter sync database job failed') + await markNewsletterRunPushFailed(runId, claim.attempt, error) + throw error + } + } + + const enqueueKey = claim.jobId ?? `newsletter_resend_${runId}_${claim.attempt}` + let jobId: string + try { + await resetFailedNewsletterRecipients(runId) + jobId = await queue.enqueue( + 'newsletter-resend-sync', + { runId, attempt: claim.attempt, requestedById }, + { + jobId: enqueueKey, + metadata: { userId: requestedById, newsletterRunId: runId }, + concurrencyKey: 'newsletter-resend-sync', + concurrencyLimit: NEWSLETTER_RESEND_SYNC_CONCURRENCY_LIMIT, + maxAttempts: NEWSLETTER_RESEND_SYNC_MAX_ATTEMPTS, + runner: async (payload, signal) => + runNewsletterResendSync(payload as NewsletterResendSyncPayload, signal), + } + ) + } catch (error) { + if (!isAsyncJobEnqueueError(error) || error.acceptance === 'rejected') { + await markNewsletterRunPushFailed(runId, claim.attempt, error) + } + throw error + } + + let updatedRun: Awaited> + try { + updatedRun = await setNewsletterRunResendJob(runId, claim.attempt, jobId) + } catch (error) { + throw new Error( + `Newsletter sync job was accepted but tracking persistence failed: ${getErrorMessage(error)}`, + { cause: error } + ) + } + return { run: updatedRun, jobId } +} diff --git a/apps/sim/lib/newsletters/resend.test.ts b/apps/sim/lib/newsletters/resend.test.ts new file mode 100644 index 00000000000..c0379b4fd62 --- /dev/null +++ b/apps/sim/lib/newsletters/resend.test.ts @@ -0,0 +1,199 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { fetchMock } = vi.hoisted(() => ({ + fetchMock: vi.fn(), +})) + +vi.mock('@/lib/core/config/env', () => ({ + env: { RESEND_API_KEY: 're_test' }, +})) + +import { + createOrSegmentNewsletterContact, + ensureNewsletterContactProperties, + getResendExcludedEmails, + getResendSuppressedEmails, +} from '@/lib/newsletters/resend' + +function jsonResponse(body: unknown, status = 200) { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }) +} + +describe('newsletter Resend service', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.stubGlobal('fetch', fetchMock) + }) + + it('creates missing newsletter contact properties', async () => { + fetchMock + .mockResolvedValueOnce( + jsonResponse({ + data: [{ id: 'property-1', key: 'sim_user_id' }], + has_more: false, + }) + ) + .mockResolvedValueOnce(jsonResponse({ id: 'property-2' }, 201)) + + await ensureNewsletterContactProperties() + + expect(fetchMock).toHaveBeenCalledTimes(2) + expect(fetchMock).toHaveBeenNthCalledWith( + 1, + 'https://api.resend.com/contact-properties?limit=100', + expect.objectContaining({ method: 'GET' }) + ) + expect(fetchMock).toHaveBeenNthCalledWith( + 2, + 'https://api.resend.com/contact-properties', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ key: 'newsletter_run_id', type: 'string' }), + }) + ) + }) + + it('creates a new contact with properties and segment membership', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse({ id: 'contact-1' }, 201)) + + const result = await createOrSegmentNewsletterContact({ + email: ' User@Example.com ', + name: 'Ada Lovelace', + userId: 'user-1', + runId: 'run-1', + segmentId: 'segment-1', + }) + + expect(result).toEqual({ status: 'created', contactId: 'contact-1' }) + expect(fetchMock).toHaveBeenCalledWith( + 'https://api.resend.com/contacts', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ + email: 'user@example.com', + first_name: 'Ada', + last_name: 'Lovelace', + properties: { + sim_user_id: 'user-1', + newsletter_run_id: 'run-1', + }, + segments: [{ id: 'segment-1' }], + }), + }) + ) + }) + + it('updates properties and segment membership for an existing contact', async () => { + fetchMock + .mockResolvedValueOnce(jsonResponse({ message: 'Contact already exists' }, 409)) + .mockResolvedValueOnce(jsonResponse({ id: 'contact-1' })) + .mockResolvedValueOnce(jsonResponse({ id: 'segment-1' })) + + const result = await createOrSegmentNewsletterContact({ + email: 'user@example.com', + name: 'Ada Lovelace', + userId: 'user-1', + runId: 'run-1', + segmentId: 'segment-1', + }) + + expect(result).toEqual({ status: 'updated', contactId: 'contact-1' }) + expect(fetchMock).toHaveBeenNthCalledWith( + 2, + 'https://api.resend.com/contacts/user%40example.com', + expect.objectContaining({ + method: 'PATCH', + body: JSON.stringify({ + properties: { + sim_user_id: 'user-1', + newsletter_run_id: 'run-1', + }, + }), + }) + ) + expect(fetchMock).toHaveBeenNthCalledWith( + 3, + 'https://api.resend.com/contacts/user%40example.com/segments/segment-1', + expect.objectContaining({ method: 'POST' }) + ) + }) + + it('normalizes suppressed email addresses', async () => { + fetchMock.mockResolvedValueOnce( + jsonResponse({ + data: [{ email: ' First@Example.com ' }, { email: 'second@example.com' }], + has_more: false, + }) + ) + + const emails = await getResendSuppressedEmails() + + expect(emails).toEqual(new Set(['first@example.com', 'second@example.com'])) + }) + + it('combines suppressions with globally unsubscribed contacts', async () => { + fetchMock + .mockResolvedValueOnce( + jsonResponse({ + data: [{ email: 'suppressed@example.com' }], + has_more: false, + }) + ) + .mockResolvedValueOnce( + jsonResponse({ + data: [ + { id: 'contact-1', email: 'subscribed@example.com', unsubscribed: false }, + { + id: 'contact-2', + email: ' Unsubscribed@Example.com ', + unsubscribed: true, + }, + ], + has_more: false, + }) + ) + + const emails = await getResendExcludedEmails() + + expect(emails).toEqual(new Set(['suppressed@example.com', 'unsubscribed@example.com'])) + }) + + it('fails closed when Resend contact pagination has no cursor', async () => { + fetchMock + .mockResolvedValueOnce(jsonResponse({ data: [], has_more: false })) + .mockResolvedValueOnce(jsonResponse({ data: [], has_more: true })) + + await expect(getResendExcludedEmails()).rejects.toThrow( + 'Resend contact pagination returned no cursor' + ) + }) + + it('fails closed on a malformed suppression response', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse({})) + + await expect(getResendSuppressedEmails()).rejects.toThrow( + 'Resend suppression list response was malformed' + ) + }) + + it('fails closed on a malformed contact entry', async () => { + fetchMock + .mockResolvedValueOnce(jsonResponse({ data: [], has_more: false })) + .mockResolvedValueOnce( + jsonResponse({ + data: [{ id: 'contact-1', email: 'user@example.com' }], + has_more: false, + }) + ) + + await expect(getResendExcludedEmails()).rejects.toThrow( + 'Resend contact list response was malformed' + ) + }) +}) diff --git a/apps/sim/lib/newsletters/resend.ts b/apps/sim/lib/newsletters/resend.ts new file mode 100644 index 00000000000..14dfa2cf31b --- /dev/null +++ b/apps/sim/lib/newsletters/resend.ts @@ -0,0 +1,278 @@ +import { getErrorMessage } from '@sim/utils/errors' +import { sleep } from '@sim/utils/helpers' +import { backoffWithJitter, parseRetryAfter } from '@sim/utils/retry' +import { z } from 'zod' +import { env } from '@/lib/core/config/env' +import { readResponseJsonWithLimit } from '@/lib/core/utils/stream-limits' + +const RESEND_API_BASE = 'https://api.resend.com' +const RESEND_MAX_ATTEMPTS = 4 +const RESEND_MAX_RESPONSE_BYTES = 10 * 1024 * 1024 +const RESEND_MAX_ERROR_BYTES = 64 * 1024 +const RESEND_CONTACT_PROPERTY_PAGE_LIMIT = 100 +const RESEND_CONTACT_PROPERTY_MAX_PAGES = 10 +const RESEND_CONTACT_PAGE_LIMIT = 100 +const RESEND_CONTACT_MAX_PAGES = 1000 +const NEWSLETTER_CONTACT_PROPERTY_KEYS = ['sim_user_id', 'newsletter_run_id'] as const + +interface ResendErrorBody { + message?: string + name?: string + error?: string +} + +interface ResendSegmentResponse { + id: string + name: string +} + +interface ResendContactResponse { + id?: string +} + +interface ResendContactPropertyListResponse { + data?: Array<{ id?: string; key?: string }> + has_more?: boolean +} + +interface ResendRequestOptions { + method?: 'GET' | 'POST' | 'PATCH' | 'DELETE' + body?: Record + required?: boolean + maxResponseBytes?: number +} + +const resendSuppressionListSchema = z.object({ + data: z.array(z.object({ email: z.string().min(1) })), + has_more: z.boolean(), +}) + +const resendContactListSchema = z.object({ + data: z.array( + z.object({ + id: z.string().min(1), + email: z.string().min(1), + unsubscribed: z.boolean(), + }) + ), + has_more: z.boolean(), +}) + +function getResendApiKey(required = true): string | null { + const key = env.RESEND_API_KEY?.trim() + if (!key || key === 'placeholder') { + if (required) throw new Error('RESEND_API_KEY is not configured') + return null + } + return key +} + +function normalizeEmail(email: string): string { + return email.trim().toLowerCase() +} + +async function readError(response: Response): Promise { + const body = await readResponseJsonWithLimit(response, { + maxBytes: RESEND_MAX_ERROR_BYTES, + label: 'Resend error response', + }).catch(() => null) + return ( + body?.message || body?.error || body?.name || `Resend request failed with ${response.status}` + ) +} + +async function resendRequest(path: string, options: ResendRequestOptions = {}): Promise { + const key = getResendApiKey(options.required) + if (!key) return undefined as T + + for (let attempt = 0; attempt < RESEND_MAX_ATTEMPTS; attempt++) { + const response = await fetch(`${RESEND_API_BASE}${path}`, { + method: options.method ?? 'GET', + headers: { + Authorization: `Bearer ${key}`, + 'Content-Type': 'application/json', + }, + body: options.body ? JSON.stringify(options.body) : undefined, + }) + + if (response.ok) { + if (response.status === 204) return {} as T + return readResponseJsonWithLimit(response, { + maxBytes: options.maxResponseBytes ?? RESEND_MAX_RESPONSE_BYTES, + label: 'Resend API response', + }) + } + + if (response.status === 429 || response.status >= 500) { + const retryAfterMs = parseRetryAfter(response.headers.get('retry-after')) + await sleep(backoffWithJitter(attempt + 1, retryAfterMs, { baseMs: 250, maxMs: 5000 })) + continue + } + + throw new Error(await readError(response)) + } + + throw new Error('Resend request failed after retries') +} + +export async function getResendSuppressedEmails(options?: { required?: boolean }) { + const rawResponse = await resendRequest('/suppressions', { + required: options?.required ?? true, + }) + if (rawResponse === undefined) return new Set() + const parsedResponse = resendSuppressionListSchema.safeParse(rawResponse) + if (!parsedResponse.success) { + throw new Error('Resend suppression list response was malformed') + } + const response = parsedResponse.data + const emails = new Set() + if (response?.has_more) { + throw new Error('Resend suppression list was incomplete') + } + for (const suppression of response.data) { + emails.add(normalizeEmail(suppression.email)) + } + return emails +} + +export async function getResendExcludedEmails(): Promise> { + const excludedEmails = await getResendSuppressedEmails({ required: true }) + let after: string | null = null + let hasMore = false + + for (let page = 0; page < RESEND_CONTACT_MAX_PAGES; page++) { + const query = new URLSearchParams({ limit: String(RESEND_CONTACT_PAGE_LIMIT) }) + if (after) query.set('after', after) + const rawContacts = await resendRequest(`/contacts?${query.toString()}`, { + required: true, + }) + const parsedContacts = resendContactListSchema.safeParse(rawContacts) + if (!parsedContacts.success) { + throw new Error('Resend contact list response was malformed') + } + const contacts = parsedContacts.data + + for (const contact of contacts.data) { + if (contact.unsubscribed) { + excludedEmails.add(normalizeEmail(contact.email)) + } + } + + hasMore = contacts.has_more + if (!hasMore) break + after = contacts.data.at(-1)?.id ?? null + if (!after) throw new Error('Resend contact pagination returned no cursor') + } + + if (hasMore) { + throw new Error('Resend contact list exceeded the pagination safety limit') + } + + return excludedEmails +} + +export async function createNewsletterSegment(name: string): Promise { + return resendRequest('/segments', { + method: 'POST', + body: { name }, + }) +} + +export async function ensureNewsletterContactProperties(): Promise { + const existingKeys = new Set() + let after: string | null = null + let hasMore = false + + for (let page = 0; page < RESEND_CONTACT_PROPERTY_MAX_PAGES; page++) { + const query = new URLSearchParams({ limit: String(RESEND_CONTACT_PROPERTY_PAGE_LIMIT) }) + if (after) query.set('after', after) + const response = await resendRequest( + `/contact-properties?${query.toString()}` + ) + + for (const property of response.data ?? []) { + if (property.key) existingKeys.add(property.key) + } + + hasMore = response.has_more ?? false + if (!hasMore) break + + after = response.data?.at(-1)?.id ?? null + if (!after) throw new Error('Resend contact property pagination returned no cursor') + } + + if (hasMore) { + throw new Error('Resend contact property list exceeded the pagination safety limit') + } + + await Promise.all( + NEWSLETTER_CONTACT_PROPERTY_KEYS.filter((key) => !existingKeys.has(key)).map(async (key) => { + try { + await resendRequest('/contact-properties', { + method: 'POST', + body: { key, type: 'string' }, + }) + } catch (error) { + const message = getErrorMessage(error) + if (!/already|exists|duplicate|conflict/i.test(message)) throw error + } + }) + ) +} + +function splitName(name: string | null): { firstName?: string; lastName?: string } { + const parts = (name ?? '').trim().split(/\s+/).filter(Boolean) + if (parts.length === 0) return {} + return { + firstName: parts[0], + ...(parts.length > 1 ? { lastName: parts.slice(1).join(' ') } : {}), + } +} + +export async function createOrSegmentNewsletterContact(input: { + email: string + name: string | null + userId: string | null + runId: string + segmentId: string +}): Promise<{ status: 'created' | 'updated' | 'segment_added'; contactId?: string }> { + const email = normalizeEmail(input.email) + const name = splitName(input.name) + + try { + const contact = await resendRequest('/contacts', { + method: 'POST', + body: { + email, + first_name: name.firstName, + last_name: name.lastName, + properties: { + sim_user_id: input.userId ?? '', + newsletter_run_id: input.runId, + }, + segments: [{ id: input.segmentId }], + }, + }) + return { status: 'created', contactId: contact.id } + } catch (error) { + const message = getErrorMessage(error) + if (!/already|exists|duplicate|conflict/i.test(message)) throw error + } + + const contact = await resendRequest( + `/contacts/${encodeURIComponent(email)}`, + { + method: 'PATCH', + body: { + properties: { + sim_user_id: input.userId ?? '', + newsletter_run_id: input.runId, + }, + }, + } + ) + await resendRequest(`/contacts/${encodeURIComponent(email)}/segments/${input.segmentId}`, { + method: 'POST', + }) + return { status: 'updated', contactId: contact.id } +} diff --git a/apps/sim/lib/newsletters/runs.ts b/apps/sim/lib/newsletters/runs.ts new file mode 100644 index 00000000000..6be6968c39a --- /dev/null +++ b/apps/sim/lib/newsletters/runs.ts @@ -0,0 +1,782 @@ +import { db } from '@sim/db' +import { + newsletterAudienceRecipients, + newsletterAudienceRuns, + settings, + user, +} from '@sim/db/schema' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { and, count, desc, eq, getTableColumns, gt, inArray, lt, ne, or, sql } from 'drizzle-orm' +import { + type NewsletterRecipientSyncStatus, + type NewsletterRun, + type NewsletterTargetingCriteria, + newsletterTargetingCriteriaSchema, +} from '@/lib/api/contracts/newsletters' +import { getResendExcludedEmails } from '@/lib/newsletters/resend' +import { + classifyNewsletterPrompt, + getNewsletterAudiencePage, + getNewsletterAudiencePreview, + NEWSLETTER_FINALIZE_PAGE_SIZE, + NEWSLETTER_FINALIZE_SAFETY_CAP, + NEWSLETTER_PREVIEW_SAMPLE_LIMIT, + type NewsletterAudienceCandidate, + type NewsletterAudienceCounts, +} from '@/lib/newsletters/targeting' +import { formatCsvValue, toCsvRow } from '@/lib/table/export-format' + +type NewsletterRunRow = typeof newsletterAudienceRuns.$inferSelect + +interface CreateNewsletterRunInput { + name: string + prompt: string + createdById: string +} + +interface FinalizeResult { + run: NewsletterRun + oversized: boolean +} + +interface NewsletterResendAttempt { + attempt: number + jobId: string | null + run: NewsletterRun + shouldEnqueue: boolean +} + +const NEWSLETTER_FINALIZATION_STALE_MS = 30 * 60 * 1000 +const simMarketingOptOutSql = sql` + ( + COALESCE((${settings.emailPreferences}->>'unsubscribeAll')::boolean, false) + OR COALESCE((${settings.emailPreferences}->>'unsubscribeMarketing')::boolean, false) + ) +` +const currentUserEligibleSql = sql` + ( + ${user.emailVerified} = true + AND COALESCE(${user.banned}, false) = false + ) +` + +function iso(date: Date | null): string | null { + return date ? date.toISOString() : null +} + +function parseCriteria(criteria: unknown): NewsletterTargetingCriteria { + return newsletterTargetingCriteriaSchema.parse(criteria) +} + +function parseSampleRecipients(value: unknown): NewsletterRun['sampleRecipients'] { + if (!Array.isArray(value)) return [] + return value + .map((entry) => { + if (!entry || typeof entry !== 'object') return null + const record = entry as Record + if (typeof record.userId !== 'string' || typeof record.email !== 'string') return null + return { + userId: record.userId, + email: record.email, + name: typeof record.name === 'string' ? record.name : null, + reason: typeof record.reason === 'string' ? record.reason : '', + } + }) + .filter((entry): entry is NewsletterRun['sampleRecipients'][number] => Boolean(entry)) +} + +export function serializeNewsletterRun(row: NewsletterRunRow): NewsletterRun { + return { + id: row.id, + name: row.name, + prompt: row.prompt, + criteria: parseCriteria(row.criteria), + status: row.status as NewsletterRun['status'], + counts: { + totalMatched: row.totalMatched, + excludedBanned: row.excludedBanned, + excludedUnverified: row.excludedUnverified, + excludedUnsubscribed: row.excludedUnsubscribed, + excludedSuppressed: row.excludedSuppressed, + finalRecipientCount: row.finalRecipientCount, + }, + sampleRecipients: parseSampleRecipients(row.sampleRecipients), + resendSegmentId: row.resendSegmentId, + resendSegmentName: row.resendSegmentName, + resendSyncedAt: iso(row.resendSyncedAt), + resendSyncJobId: row.resendSyncJobId, + error: row.error, + finalizedAt: iso(row.finalizedAt), + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + } +} + +export async function getNewsletterRun(id: string): Promise { + const [row] = await db + .select() + .from(newsletterAudienceRuns) + .where(eq(newsletterAudienceRuns.id, id)) + .limit(1) + return row ? serializeNewsletterRun(row) : null +} + +export async function requireNewsletterRun(id: string): Promise { + const run = await getNewsletterRun(id) + if (!run) throw new Error('Newsletter run not found') + return run +} + +export async function listNewsletterRuns(limit: number, offset: number) { + const [rows, totalRows] = await Promise.all([ + db + .select() + .from(newsletterAudienceRuns) + .orderBy(desc(newsletterAudienceRuns.createdAt)) + .limit(limit) + .offset(offset), + db.select({ total: count() }).from(newsletterAudienceRuns), + ]) + + return { + runs: rows.map(serializeNewsletterRun), + total: totalRows[0]?.total ?? 0, + } +} + +function sampleFromCandidates(candidates: NewsletterAudienceCandidate[]) { + return candidates.map((candidate) => ({ + userId: candidate.userId, + email: candidate.email, + name: candidate.name, + reason: candidate.reason, + })) +} + +function inclusionReason(value: unknown): string { + if (!value || typeof value !== 'object' || !('reason' in value)) return '' + return String((value as { reason?: unknown }).reason ?? '') +} + +async function sampleFromSnapshot( + runId: string, + snapshotVersion: number +): Promise { + const rows = await db + .select({ + userId: newsletterAudienceRecipients.userId, + email: newsletterAudienceRecipients.email, + name: newsletterAudienceRecipients.name, + inclusionReason: newsletterAudienceRecipients.inclusionReason, + }) + .from(newsletterAudienceRecipients) + .where( + and( + eq(newsletterAudienceRecipients.runId, runId), + eq(newsletterAudienceRecipients.snapshotVersion, snapshotVersion) + ) + ) + .orderBy(newsletterAudienceRecipients.email) + .limit(NEWSLETTER_PREVIEW_SAMPLE_LIMIT) + + return rows.flatMap((row) => + row.userId + ? [ + { + userId: row.userId, + email: row.email, + name: row.name, + reason: inclusionReason(row.inclusionReason), + }, + ] + : [] + ) +} + +export async function createNewsletterRun({ + name, + prompt, + createdById, +}: CreateNewsletterRunInput): Promise { + const criteria = classifyNewsletterPrompt(prompt) + const preview = await getNewsletterAudiencePreview(criteria) + const now = new Date() + const [row] = await db + .insert(newsletterAudienceRuns) + .values({ + id: generateId(), + createdById, + name, + prompt, + criteria, + status: 'draft', + totalMatched: preview.counts.totalMatched, + excludedBanned: preview.counts.excludedBanned, + excludedUnverified: preview.counts.excludedUnverified, + excludedUnsubscribed: preview.counts.excludedUnsubscribed, + excludedSuppressed: 0, + finalRecipientCount: preview.counts.finalRecipientCount, + sampleRecipients: sampleFromCandidates(preview.sample), + createdAt: now, + updatedAt: now, + }) + .returning() + return serializeNewsletterRun(row) +} + +function lowerEmail(email: string): string { + return email.trim().toLowerCase() +} + +async function insertRecipientChunk( + runId: string, + candidates: NewsletterAudienceCandidate[], + suppressedEmails: Set, + seenEmails: Set, + snapshotVersion: number +): Promise<{ inserted: number; suppressed: number }> { + let suppressed = 0 + const now = new Date() + const values: (typeof newsletterAudienceRecipients.$inferInsert)[] = [] + + for (const candidate of candidates) { + const email = lowerEmail(candidate.email) + if (!email || seenEmails.has(email)) continue + seenEmails.add(email) + if (suppressedEmails.has(email)) { + suppressed++ + continue + } + values.push({ + id: generateId(), + runId, + snapshotVersion, + userId: candidate.userId, + email, + name: candidate.name, + inclusionReason: { reason: candidate.reason }, + resendStatus: 'pending', + createdAt: now, + updatedAt: now, + }) + } + + if (values.length > 0) { + await db.insert(newsletterAudienceRecipients).values(values).onConflictDoNothing() + } + + return { inserted: values.length, suppressed } +} + +export async function finalizeNewsletterRun(id: string): Promise { + const staleBefore = new Date(Date.now() - NEWSLETTER_FINALIZATION_STALE_MS) + const [claimed] = await db + .update(newsletterAudienceRuns) + .set({ + status: 'finalizing', + snapshotVersion: sql`${newsletterAudienceRuns.snapshotVersion} + 1`, + updatedAt: new Date(), + error: null, + }) + .where( + and( + eq(newsletterAudienceRuns.id, id), + or( + eq(newsletterAudienceRuns.status, 'draft'), + and( + eq(newsletterAudienceRuns.status, 'finalizing'), + lt(newsletterAudienceRuns.updatedAt, staleBefore) + ) + ) + ) + ) + .returning() + + if (!claimed) { + const [current] = await db + .select() + .from(newsletterAudienceRuns) + .where(eq(newsletterAudienceRuns.id, id)) + .limit(1) + if (!current) throw new Error('Newsletter run not found') + if (current.status === 'finalizing') { + throw new Error('Newsletter audience finalization is already in progress') + } + return { run: serializeNewsletterRun(current), oversized: current.status === 'oversized' } + } + + const snapshotVersion = claimed.snapshotVersion + try { + const criteria = parseCriteria(claimed.criteria) + const [preview, suppressedEmails] = await Promise.all([ + getNewsletterAudiencePreview(criteria, 'primary'), + getResendExcludedEmails(), + ]) + const seenEmails = new Set() + let inserted = 0 + let suppressed = 0 + let afterUserId: string | null = null + let scanned = 0 + + while (scanned <= NEWSLETTER_FINALIZE_SAFETY_CAP) { + const pageLimit = Math.min( + NEWSLETTER_FINALIZE_PAGE_SIZE, + NEWSLETTER_FINALIZE_SAFETY_CAP + 1 - scanned + ) + const candidates = await getNewsletterAudiencePage( + criteria, + pageLimit, + afterUserId, + true, + 'primary' + ) + if (candidates.length === 0) break + const result = await insertRecipientChunk( + id, + candidates, + suppressedEmails, + seenEmails, + snapshotVersion + ) + inserted += result.inserted + suppressed += result.suppressed + scanned += candidates.length + afterUserId = candidates.at(-1)?.userId ?? afterUserId + if (scanned > NEWSLETTER_FINALIZE_SAFETY_CAP || candidates.length < pageLimit) break + } + + if (scanned > NEWSLETTER_FINALIZE_SAFETY_CAP) { + await db + .delete(newsletterAudienceRecipients) + .where( + and( + eq(newsletterAudienceRecipients.runId, id), + eq(newsletterAudienceRecipients.snapshotVersion, snapshotVersion) + ) + ) + const [oversized] = await db + .update(newsletterAudienceRuns) + .set({ + status: 'oversized', + totalMatched: preview.counts.totalMatched, + excludedBanned: preview.counts.excludedBanned, + excludedUnverified: preview.counts.excludedUnverified, + excludedUnsubscribed: preview.counts.excludedUnsubscribed, + excludedSuppressed: suppressed, + finalRecipientCount: 0, + sampleRecipients: [], + updatedAt: new Date(), + error: `Audience exceeds the ${NEWSLETTER_FINALIZE_SAFETY_CAP} recipient safety limit; create a narrower run`, + }) + .where( + and( + eq(newsletterAudienceRuns.id, id), + eq(newsletterAudienceRuns.status, 'finalizing'), + eq(newsletterAudienceRuns.snapshotVersion, snapshotVersion) + ) + ) + .returning() + if (!oversized) throw new Error('Newsletter finalization was superseded') + return { run: serializeNewsletterRun(oversized), oversized: true } + } + + const counts: NewsletterAudienceCounts = { + ...preview.counts, + excludedSuppressed: suppressed, + finalRecipientCount: inserted, + } + const snapshotSample = await sampleFromSnapshot(id, snapshotVersion) + const now = new Date() + const [updated] = await db + .update(newsletterAudienceRuns) + .set({ + status: 'finalized', + totalMatched: counts.totalMatched, + excludedBanned: counts.excludedBanned, + excludedUnverified: counts.excludedUnverified, + excludedUnsubscribed: counts.excludedUnsubscribed, + excludedSuppressed: counts.excludedSuppressed, + finalRecipientCount: counts.finalRecipientCount, + sampleRecipients: snapshotSample, + finalizedAt: now, + updatedAt: now, + error: null, + }) + .where( + and( + eq(newsletterAudienceRuns.id, id), + eq(newsletterAudienceRuns.status, 'finalizing'), + eq(newsletterAudienceRuns.snapshotVersion, snapshotVersion) + ) + ) + .returning() + + if (!updated) throw new Error('Newsletter finalization was superseded') + await db + .delete(newsletterAudienceRecipients) + .where( + and( + eq(newsletterAudienceRecipients.runId, id), + ne(newsletterAudienceRecipients.snapshotVersion, snapshotVersion) + ) + ) + return { run: serializeNewsletterRun(updated), oversized: false } + } catch (error) { + await db + .delete(newsletterAudienceRecipients) + .where( + and( + eq(newsletterAudienceRecipients.runId, id), + eq(newsletterAudienceRecipients.snapshotVersion, snapshotVersion) + ) + ) + await db + .update(newsletterAudienceRuns) + .set({ + status: 'draft', + error: getErrorMessage(error, 'Newsletter finalization failed'), + updatedAt: new Date(), + }) + .where( + and( + eq(newsletterAudienceRuns.id, id), + eq(newsletterAudienceRuns.status, 'finalizing'), + eq(newsletterAudienceRuns.snapshotVersion, snapshotVersion) + ) + ) + throw error + } +} + +export async function claimNewsletterRunResendAttempt( + runId: string +): Promise { + const [claimed] = await db + .update(newsletterAudienceRuns) + .set({ + status: 'pushing', + resendSyncAttempt: sql`${newsletterAudienceRuns.resendSyncAttempt} + 1`, + resendSyncJobId: null, + error: null, + updatedAt: new Date(), + }) + .where( + and( + eq(newsletterAudienceRuns.id, runId), + inArray(newsletterAudienceRuns.status, ['finalized', 'failed']) + ) + ) + .returning() + + if (claimed) { + return { + attempt: claimed.resendSyncAttempt, + jobId: null, + run: serializeNewsletterRun(claimed), + shouldEnqueue: true, + } + } + + const [current] = await db + .select() + .from(newsletterAudienceRuns) + .where(eq(newsletterAudienceRuns.id, runId)) + .limit(1) + if (!current) throw new Error('Newsletter run not found') + if (current.status === 'pushed') { + return { + attempt: current.resendSyncAttempt, + jobId: current.resendSyncJobId, + run: serializeNewsletterRun(current), + shouldEnqueue: current.resendSyncJobId === null, + } + } + if (current.status === 'pushing') { + return { + attempt: current.resendSyncAttempt, + jobId: current.resendSyncJobId, + run: serializeNewsletterRun(current), + shouldEnqueue: current.resendSyncJobId === null, + } + } + throw new Error('Finalize the newsletter audience before pushing to Resend') +} + +export async function setNewsletterRunResendJob(runId: string, attempt: number, jobId: string) { + const [updated] = await db + .update(newsletterAudienceRuns) + .set({ + resendSyncJobId: jobId, + updatedAt: new Date(), + }) + .where( + and( + eq(newsletterAudienceRuns.id, runId), + eq(newsletterAudienceRuns.resendSyncAttempt, attempt), + inArray(newsletterAudienceRuns.status, ['pushing', 'failed', 'pushed']) + ) + ) + .returning() + if (!updated) throw new Error('Newsletter Resend enqueue attempt was superseded') + return serializeNewsletterRun(updated) +} + +export async function markNewsletterRunPushFailed(runId: string, attempt: number, error: unknown) { + await db + .update(newsletterAudienceRuns) + .set({ + status: 'failed', + error: getErrorMessage(error, 'Newsletter push failed'), + updatedAt: new Date(), + }) + .where( + and( + eq(newsletterAudienceRuns.id, runId), + eq(newsletterAudienceRuns.resendSyncAttempt, attempt), + inArray(newsletterAudienceRuns.status, ['pushing', 'failed']) + ) + ) +} + +export async function markNewsletterRunPushed( + runId: string, + attempt: number, + segmentId: string, + segmentName: string +) { + await db + .update(newsletterAudienceRuns) + .set({ + status: 'pushed', + resendSegmentId: segmentId, + resendSegmentName: segmentName, + resendSyncedAt: new Date(), + error: null, + updatedAt: new Date(), + }) + .where( + and( + eq(newsletterAudienceRuns.id, runId), + eq(newsletterAudienceRuns.resendSyncAttempt, attempt), + inArray(newsletterAudienceRuns.status, ['pushing', 'failed']) + ) + ) +} + +export async function setNewsletterRunResendSegment( + runId: string, + attempt: number, + segmentId: string, + segmentName: string +) { + await db + .update(newsletterAudienceRuns) + .set({ + resendSegmentId: segmentId, + resendSegmentName: segmentName, + updatedAt: new Date(), + }) + .where( + and( + eq(newsletterAudienceRuns.id, runId), + eq(newsletterAudienceRuns.resendSyncAttempt, attempt) + ) + ) +} + +const NEWSLETTER_CSV_PAGE_SIZE = 1000 + +async function* generateNewsletterCsvLines( + runId: string, + runSnapshotVersion: number, + resendExcludedEmails: Set +): AsyncGenerator { + yield toCsvRow(['email', 'first_name', 'last_name', 'sim_user_id', 'inclusion_reason']) + + let afterEmail: string | null = null + while (true) { + const conditions = [ + eq(newsletterAudienceRecipients.runId, runId), + eq(newsletterAudienceRecipients.snapshotVersion, runSnapshotVersion), + currentUserEligibleSql, + sql`lower(${user.email}) = ${newsletterAudienceRecipients.email}`, + sql`${simMarketingOptOutSql} = false`, + ] + if (afterEmail) conditions.push(gt(newsletterAudienceRecipients.email, afterEmail)) + + const rows = await db + .select({ + email: newsletterAudienceRecipients.email, + name: newsletterAudienceRecipients.name, + userId: newsletterAudienceRecipients.userId, + inclusionReason: newsletterAudienceRecipients.inclusionReason, + }) + .from(newsletterAudienceRecipients) + .innerJoin(user, eq(user.id, newsletterAudienceRecipients.userId)) + .leftJoin(settings, eq(settings.userId, newsletterAudienceRecipients.userId)) + .where(and(...conditions)) + .orderBy(newsletterAudienceRecipients.email) + .limit(NEWSLETTER_CSV_PAGE_SIZE) + + for (const row of rows) { + if (resendExcludedEmails.has(lowerEmail(row.email))) continue + const [firstName, ...rest] = (row.name ?? '').trim().split(/\s+/).filter(Boolean) + const reason = inclusionReason(row.inclusionReason) + yield toCsvRow([ + formatCsvValue(row.email), + formatCsvValue(firstName ?? ''), + formatCsvValue(rest.join(' ')), + formatCsvValue(row.userId ?? ''), + formatCsvValue(reason), + ]) + } + + if (rows.length < NEWSLETTER_CSV_PAGE_SIZE) break + afterEmail = rows.at(-1)?.email ?? afterEmail + } +} + +export async function createNewsletterCsvExport( + runId: string +): Promise<{ filename: string; lines: AsyncGenerator }> { + const run = await requireNewsletterRun(runId) + if (!['finalized', 'pushing', 'pushed', 'failed'].includes(run.status)) { + throw new Error('Finalize the newsletter audience before exporting CSV') + } + + const [row] = await db + .select({ snapshotVersion: newsletterAudienceRuns.snapshotVersion }) + .from(newsletterAudienceRuns) + .where(eq(newsletterAudienceRuns.id, runId)) + .limit(1) + if (!row) throw new Error('Newsletter run not found') + const resendExcludedEmails = await getResendExcludedEmails() + + return { + filename: `newsletter-${run.name.replace(/[^a-zA-Z0-9_-]+/g, '_')}-${run.id.slice(0, 8)}.csv`, + lines: generateNewsletterCsvLines(runId, row.snapshotVersion, resendExcludedEmails), + } +} + +export async function countNewsletterRecipientsByStatus(runId: string) { + const [run] = await db + .select({ snapshotVersion: newsletterAudienceRuns.snapshotVersion }) + .from(newsletterAudienceRuns) + .where(eq(newsletterAudienceRuns.id, runId)) + .limit(1) + if (!run) throw new Error('Newsletter run not found') + + const rows = await db + .select({ + status: newsletterAudienceRecipients.resendStatus, + total: count(), + }) + .from(newsletterAudienceRecipients) + .where( + and( + eq(newsletterAudienceRecipients.runId, runId), + eq(newsletterAudienceRecipients.snapshotVersion, run.snapshotVersion) + ) + ) + .groupBy(newsletterAudienceRecipients.resendStatus) + + return Object.fromEntries(rows.map((row) => [row.status, row.total])) +} + +export async function updateRecipientSyncStatus( + runId: string, + snapshotVersion: number, + email: string, + status: NewsletterRecipientSyncStatus, + options?: { contactId?: string; error?: string | null } +) { + await db + .update(newsletterAudienceRecipients) + .set({ + resendStatus: status, + resendContactId: options?.contactId, + error: options?.error ?? null, + updatedAt: new Date(), + }) + .where( + and( + eq(newsletterAudienceRecipients.runId, runId), + eq(newsletterAudienceRecipients.snapshotVersion, snapshotVersion), + eq(newsletterAudienceRecipients.email, email) + ) + ) +} + +export async function getPendingNewsletterRecipients(runId: string, limit: number) { + const [run] = await db + .select({ snapshotVersion: newsletterAudienceRuns.snapshotVersion }) + .from(newsletterAudienceRuns) + .where(eq(newsletterAudienceRuns.id, runId)) + .limit(1) + if (!run) throw new Error('Newsletter run not found') + + return db + .select({ + ...getTableColumns(newsletterAudienceRecipients), + simUnsubscribed: simMarketingOptOutSql, + currentUserEligible: sql` + ( + ${user.id} IS NOT NULL + AND ${currentUserEligibleSql} + AND lower(${user.email}) = ${newsletterAudienceRecipients.email} + ) + `, + }) + .from(newsletterAudienceRecipients) + .leftJoin(user, eq(user.id, newsletterAudienceRecipients.userId)) + .leftJoin(settings, eq(settings.userId, newsletterAudienceRecipients.userId)) + .where( + and( + eq(newsletterAudienceRecipients.runId, runId), + eq(newsletterAudienceRecipients.snapshotVersion, run.snapshotVersion), + eq(newsletterAudienceRecipients.resendStatus, 'pending') + ) + ) + .orderBy(newsletterAudienceRecipients.email) + .limit(limit) +} + +export async function resetFailedNewsletterRecipients(runId: string) { + const [run] = await db + .select({ snapshotVersion: newsletterAudienceRuns.snapshotVersion }) + .from(newsletterAudienceRuns) + .where(eq(newsletterAudienceRuns.id, runId)) + .limit(1) + if (!run) throw new Error('Newsletter run not found') + + await db + .update(newsletterAudienceRecipients) + .set({ + resendStatus: 'pending', + error: null, + updatedAt: new Date(), + }) + .where( + and( + eq(newsletterAudienceRecipients.runId, runId), + eq(newsletterAudienceRecipients.snapshotVersion, run.snapshotVersion), + eq(newsletterAudienceRecipients.resendStatus, 'failed') + ) + ) +} + +export async function requireNewsletterRunAttempt(runId: string, attempt: number) { + const [row] = await db + .select() + .from(newsletterAudienceRuns) + .where( + and( + eq(newsletterAudienceRuns.id, runId), + eq(newsletterAudienceRuns.resendSyncAttempt, attempt) + ) + ) + .limit(1) + if (!row) throw new Error('Newsletter Resend sync attempt was superseded') + return serializeNewsletterRun(row) +} diff --git a/apps/sim/lib/newsletters/targeting.test.ts b/apps/sim/lib/newsletters/targeting.test.ts new file mode 100644 index 00000000000..3f9767b8b80 --- /dev/null +++ b/apps/sim/lib/newsletters/targeting.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from 'vitest' +import { classifyNewsletterPrompt } from '@/lib/newsletters/targeting' + +describe('classifyNewsletterPrompt', () => { + it('maps everyone prompts to the everyone template', () => { + expect(classifyNewsletterPrompt('newsletter to everyone')).toEqual({ type: 'everyone' }) + }) + + it('maps Instagram integration prompts to the integration template', () => { + expect(classifyNewsletterPrompt('Users who use the Instagram integration')).toEqual({ + type: 'integration_users', + integration: 'instagram', + timeWindowDays: null, + }) + }) + + it('keeps recent activity windows scoped to an Instagram target', () => { + expect(classifyNewsletterPrompt('Instagram users active in the last 30 days')).toEqual({ + type: 'integration_users', + integration: 'instagram', + timeWindowDays: 30, + }) + }) + + it('maps Instagram chat context prompts to the chat mention template', () => { + expect(classifyNewsletterPrompt('Instagram chat context in the last 90 days')).toEqual({ + type: 'chat_mentions', + term: 'instagram', + timeWindowDays: 90, + }) + }) + + it('defaults Instagram chat context to a bounded window', () => { + expect(classifyNewsletterPrompt('Users whose chat context mentions Instagram')).toEqual({ + type: 'chat_mentions', + term: 'instagram', + timeWindowDays: 90, + }) + }) + + it('maps recent activity prompts to a bounded recent activity template', () => { + expect(classifyNewsletterPrompt('recently active users in the last 30 days')).toEqual({ + type: 'recently_active', + timeWindowDays: 30, + }) + }) + + it('rejects ambiguous prompts instead of falling back to everyone', () => { + expect(() => classifyNewsletterPrompt('Users interested in productivity')).toThrow( + 'Targeting prompt is ambiguous' + ) + }) + + it('rejects negated prompts that would otherwise broaden to everyone', () => { + expect(() => classifyNewsletterPrompt('Do not email everyone')).toThrow( + 'Targeting prompt is ambiguous' + ) + }) + + it('rejects broadening avoidance language', () => { + expect(() => classifyNewsletterPrompt('Avoid everyone')).toThrow( + 'Targeting prompt is ambiguous' + ) + expect(() => classifyNewsletterPrompt('Anyone but everyone')).toThrow( + 'Targeting prompt is ambiguous' + ) + expect(() => classifyNewsletterPrompt('Skip everyone')).toThrow('Targeting prompt is ambiguous') + expect(() => classifyNewsletterPrompt('Omit everyone')).toThrow('Targeting prompt is ambiguous') + expect(() => classifyNewsletterPrompt('Everyone aside from recently active users')).toThrow( + 'Targeting prompt is ambiguous' + ) + }) + + it('rejects mixed targeting templates', () => { + expect(() => + classifyNewsletterPrompt('Everyone plus users who use the Instagram integration') + ).toThrow('Targeting prompt is ambiguous') + expect(() => + classifyNewsletterPrompt('Instagram integration users and recently active users') + ).toThrow('Targeting prompt is ambiguous') + }) + + it('bounds scoped Instagram activity when no window is supplied', () => { + expect(classifyNewsletterPrompt('Instagram integration users recently active')).toEqual({ + type: 'integration_users', + integration: 'instagram', + timeWindowDays: 30, + }) + }) +}) diff --git a/apps/sim/lib/newsletters/targeting.ts b/apps/sim/lib/newsletters/targeting.ts new file mode 100644 index 00000000000..6f4e7ec4bf9 --- /dev/null +++ b/apps/sim/lib/newsletters/targeting.ts @@ -0,0 +1,332 @@ +import { db, dbReplica } from '@sim/db' +import { copilotMessages, workflow } from '@sim/db/schema' +import { sql } from 'drizzle-orm' +import type { NewsletterTargetingCriteria } from '@/lib/api/contracts/newsletters' + +export const NEWSLETTER_PREVIEW_SAMPLE_LIMIT = 50 +export const NEWSLETTER_FINALIZE_PAGE_SIZE = 1000 +export const NEWSLETTER_FINALIZE_SAFETY_CAP = 100000 + +export interface NewsletterAudienceCandidate { + userId: string + email: string + name: string | null + emailVerified: boolean + banned: boolean + unsubscribed: boolean + reason: string +} + +export interface NewsletterAudienceCounts { + totalMatched: number + excludedBanned: number + excludedUnverified: number + excludedUnsubscribed: number + excludedSuppressed: number + finalRecipientCount: number +} + +interface CandidateRow extends Record { + user_id: string + email: string + name: string | null + email_verified: boolean + banned: boolean | null + unsubscribed: boolean + reason: string +} + +interface CountRow extends Record { + total_matched: number | string + excluded_banned: number | string + excluded_unverified: number | string + excluded_unsubscribed: number | string + final_recipient_count: number | string +} + +export class NewsletterTargetingPromptError extends Error { + constructor() { + super( + 'Targeting prompt is ambiguous. Use everyone, an Instagram integration or chat target, or a recent activity window.' + ) + this.name = 'NewsletterTargetingPromptError' + } +} + +function parseDayWindow(prompt: string): number | null { + const match = prompt.match(/(?:last|past|within)\s+(\d{1,4})\s+days?/i) + if (!match) return null + const parsed = Number.parseInt(match[1], 10) + if (!Number.isFinite(parsed)) return null + return Math.max(1, Math.min(parsed, 3650)) +} + +function normalizePrompt(prompt: string): string { + return prompt + .trim() + .toLowerCase() + .replace(/[.!?]+$/, '') + .replace(/\s+/g, ' ') +} + +export function classifyNewsletterPrompt(prompt: string): NewsletterTargetingCriteria { + const normalized = normalizePrompt(prompt) + const timeWindowDays = parseDayWindow(normalized) + + if ( + [ + 'everyone', + 'newsletter to everyone', + 'newsletters to everyone', + 'send to everyone', + 'send newsletter to everyone', + 'all users', + 'all customers', + 'all people', + ].includes(normalized) + ) { + return { type: 'everyone' } + } + + const chatTargetPatterns = [ + /^users whose chat context mentions instagram(?: (?:in (?:the )?(?:last|past)|within) \d{1,4} days?)?$/, + /^instagram chat context(?: (?:in (?:the )?(?:last|past)|within) \d{1,4} days?)?$/, + /^users who (?:mentioned|asked about|talked about) instagram in (?:copilot )?chat(?: (?:in (?:the )?(?:last|past)|within) \d{1,4} days?)?$/, + ] + if (chatTargetPatterns.some((pattern) => pattern.test(normalized))) { + return { + type: 'chat_mentions', + term: 'instagram', + timeWindowDays: Math.min(timeWindowDays ?? 90, 365), + } + } + + const integrationTargetPattern = + /^(?:users who (?:use|are connected to) (?:the )?instagram(?: integration)?|users using (?:the )?instagram(?: integration)?|instagram integration users|instagram users)(?: (?:recently )?active)?(?: (?:in (?:the )?(?:last|past)|within) \d{1,4} days?)?$/ + if (integrationTargetPattern.test(normalized)) { + return { + type: 'integration_users', + integration: 'instagram', + timeWindowDays: timeWindowDays ?? (/\b(?:recently )?active\b/.test(normalized) ? 30 : null), + } + } + + const recentActivityPattern = + /^(?:recently active users|users (?:recently )?active|users who (?:use|used) sim)(?: (?:in (?:the )?(?:last|past)|within) \d{1,4} days?)?$/ + if (recentActivityPattern.test(normalized)) { + return { type: 'recently_active', timeWindowDays: timeWindowDays ?? 30 } + } + + throw new NewsletterTargetingPromptError() +} + +function cutoffForDays(days: number | null): Date | null { + if (!days) return null + return new Date(Date.now() - days * 24 * 60 * 60 * 1000) +} + +const emailOptOutSql = sql` + ( + COALESCE((s.email_preferences->>'unsubscribeAll')::boolean, false) + OR COALESCE((s.email_preferences->>'unsubscribeMarketing')::boolean, false) + ) +` + +function matchedUsersSql(criteria: NewsletterTargetingCriteria) { + if (criteria.type === 'everyone') { + return sql` + SELECT DISTINCT + u.id AS user_id, + u.email, + u.name, + u.email_verified, + u.banned, + ${emailOptOutSql} AS unsubscribed, + 'Everyone with a Sim account'::text AS reason + FROM "user" u + LEFT JOIN settings s ON s.user_id = u.id + WHERE u.email IS NOT NULL AND u.email <> '' + ` + } + + if (criteria.type === 'integration_users') { + const integration = criteria.integration.toLowerCase() + const cutoff = cutoffForDays(criteria.timeWindowDays) + const workflowCutoff = cutoff ? sql.param(cutoff, workflow.updatedAt) : null + + return sql` + SELECT DISTINCT + u.id AS user_id, + u.email, + u.name, + u.email_verified, + u.banned, + ${emailOptOutSql} AS unsubscribed, + ${`Workflow uses the ${integration} integration`}::text AS reason + FROM "user" u + LEFT JOIN settings s ON s.user_id = u.id + INNER JOIN workflow w ON w.user_id = u.id + INNER JOIN workflow_blocks wb ON wb.workflow_id = w.id + WHERE u.email IS NOT NULL + AND u.email <> '' + AND w.archived_at IS NULL + AND wb.enabled = true + AND wb.type = ${integration} + ${workflowCutoff ? sql`AND w.updated_at >= ${workflowCutoff}` : sql``} + ` + } + + if (criteria.type === 'chat_mentions') { + const term = criteria.term.toLowerCase() + const pattern = `%${term}%` + const cutoff = cutoffForDays(criteria.timeWindowDays) + const messageCutoff = cutoff ? sql.param(cutoff, copilotMessages.createdAt) : null + + return sql` + SELECT DISTINCT + u.id AS user_id, + u.email, + u.name, + u.email_verified, + u.banned, + ${emailOptOutSql} AS unsubscribed, + ${`Copilot chat context mentions ${term}`}::text AS reason + FROM "user" u + LEFT JOIN settings s ON s.user_id = u.id + INNER JOIN copilot_chats cc ON cc.user_id = u.id + INNER JOIN copilot_messages cm ON cm.chat_id = cc.id + WHERE u.email IS NOT NULL + AND u.email <> '' + AND cc.deleted_at IS NULL + AND cm.deleted_at IS NULL + AND cm.role = 'user' + AND lower(cm.content::text) LIKE ${pattern} + ${messageCutoff ? sql`AND cm.created_at >= ${messageCutoff}` : sql``} + ` + } + + const cutoff = cutoffForDays(criteria.timeWindowDays) ?? new Date(Date.now() - 30 * 86400000) + const workflowCutoff = sql.param(cutoff, workflow.updatedAt) + const messageCutoff = sql.param(cutoff, copilotMessages.createdAt) + + return sql` + SELECT DISTINCT + u.id AS user_id, + u.email, + u.name, + u.email_verified, + u.banned, + ${emailOptOutSql} AS unsubscribed, + ${`Active in the last ${criteria.timeWindowDays} days`}::text AS reason + FROM "user" u + LEFT JOIN settings s ON s.user_id = u.id + WHERE u.email IS NOT NULL + AND u.email <> '' + AND ( + EXISTS ( + SELECT 1 + FROM workflow w + WHERE w.user_id = u.id + AND w.archived_at IS NULL + AND ( + w.updated_at >= ${workflowCutoff} + OR w.last_run_at >= ${workflowCutoff} + ) + ) + OR EXISTS ( + SELECT 1 + FROM copilot_chats cc + INNER JOIN copilot_messages cm ON cm.chat_id = cc.id + WHERE cc.user_id = u.id + AND cc.deleted_at IS NULL + AND cm.deleted_at IS NULL + AND cm.created_at >= ${messageCutoff} + ) + ) + ` +} + +function toNumber(value: number | string): number { + return typeof value === 'number' ? value : Number.parseInt(value, 10) +} + +function rowToCandidate(row: CandidateRow): NewsletterAudienceCandidate { + return { + userId: row.user_id, + email: row.email, + name: row.name, + emailVerified: row.email_verified, + banned: row.banned ?? false, + unsubscribed: row.unsubscribed, + reason: row.reason, + } +} + +export async function getNewsletterAudienceCounts( + criteria: NewsletterTargetingCriteria, + source: 'primary' | 'replica' = 'replica' +): Promise { + const client = source === 'primary' ? db : dbReplica + const result = await client.execute(sql` + WITH matched AS (${matchedUsersSql(criteria)}) + SELECT + count(*)::int AS total_matched, + count(*) FILTER (WHERE COALESCE(banned, false))::int AS excluded_banned, + count(*) FILTER (WHERE NOT email_verified)::int AS excluded_unverified, + count(*) FILTER (WHERE unsubscribed)::int AS excluded_unsubscribed, + count(*) FILTER ( + WHERE email_verified = true + AND COALESCE(banned, false) = false + AND unsubscribed = false + )::int AS final_recipient_count + FROM matched + `) + const row = result[0] + return { + totalMatched: row ? toNumber(row.total_matched) : 0, + excludedBanned: row ? toNumber(row.excluded_banned) : 0, + excludedUnverified: row ? toNumber(row.excluded_unverified) : 0, + excludedUnsubscribed: row ? toNumber(row.excluded_unsubscribed) : 0, + excludedSuppressed: 0, + finalRecipientCount: row ? toNumber(row.final_recipient_count) : 0, + } +} + +export async function getNewsletterAudiencePage( + criteria: NewsletterTargetingCriteria, + limit: number, + afterUserId: string | null = null, + marketableOnly = true, + source: 'primary' | 'replica' = 'replica' +): Promise { + const client = source === 'primary' ? db : dbReplica + const result = await client.execute(sql` + WITH matched AS (${matchedUsersSql(criteria)}) + SELECT user_id, email, name, email_verified, banned, unsubscribed, reason + FROM matched + WHERE ${ + marketableOnly + ? sql` + email_verified = true + AND COALESCE(banned, false) = false + AND unsubscribed = false + ` + : sql`true` + } + ${afterUserId ? sql`AND user_id > ${afterUserId}` : sql``} + ORDER BY user_id ASC + LIMIT ${limit} + `) + return result.map(rowToCandidate) +} + +export async function getNewsletterAudiencePreview( + criteria: NewsletterTargetingCriteria, + source: 'primary' | 'replica' = 'replica' +) { + const [counts, sample] = await Promise.all([ + getNewsletterAudienceCounts(criteria, source), + getNewsletterAudiencePage(criteria, NEWSLETTER_PREVIEW_SAMPLE_LIMIT, null, true, source), + ]) + return { counts, sample } +} diff --git a/packages/db/migrations/0277_condemned_sandman.sql b/packages/db/migrations/0277_condemned_sandman.sql new file mode 100644 index 00000000000..b47d828e662 --- /dev/null +++ b/packages/db/migrations/0277_condemned_sandman.sql @@ -0,0 +1,53 @@ +CREATE TABLE "newsletter_audience_recipients" ( + "id" text PRIMARY KEY NOT NULL, + "run_id" text NOT NULL, + "user_id" text, + "email" text NOT NULL, + "name" text, + "inclusion_reason" jsonb DEFAULT '{}' NOT NULL, + "snapshot_version" integer NOT NULL, + "resend_contact_id" text, + "resend_status" text DEFAULT 'pending' NOT NULL, + "error" text, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "newsletter_audience_runs" ( + "id" text PRIMARY KEY NOT NULL, + "created_by_id" text, + "name" text NOT NULL, + "prompt" text NOT NULL, + "criteria" jsonb NOT NULL, + "status" text DEFAULT 'draft' NOT NULL, + "total_matched" integer DEFAULT 0 NOT NULL, + "excluded_banned" integer DEFAULT 0 NOT NULL, + "excluded_unverified" integer DEFAULT 0 NOT NULL, + "excluded_unsubscribed" integer DEFAULT 0 NOT NULL, + "excluded_suppressed" integer DEFAULT 0 NOT NULL, + "final_recipient_count" integer DEFAULT 0 NOT NULL, + "sample_recipients" jsonb DEFAULT '[]' NOT NULL, + "resend_segment_id" text, + "resend_segment_name" text, + "resend_synced_at" timestamp, + "snapshot_version" integer DEFAULT 0 NOT NULL, + "resend_sync_attempt" integer DEFAULT 0 NOT NULL, + "resend_sync_job_id" text, + "error" text, + "finalized_at" timestamp, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "newsletter_audience_recipients" ADD CONSTRAINT "newsletter_audience_recipients_run_id_newsletter_audience_runs_id_fk" FOREIGN KEY ("run_id") REFERENCES "public"."newsletter_audience_runs"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "newsletter_audience_recipients" ADD CONSTRAINT "newsletter_audience_recipients_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "newsletter_audience_runs" ADD CONSTRAINT "newsletter_audience_runs_created_by_id_user_id_fk" FOREIGN KEY ("created_by_id") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "newsletter_audience_recipients_run_id_idx" ON "newsletter_audience_recipients" USING btree ("run_id");--> statement-breakpoint +CREATE INDEX "newsletter_audience_recipients_user_id_idx" ON "newsletter_audience_recipients" USING btree ("user_id");--> statement-breakpoint +CREATE INDEX "newsletter_audience_recipients_email_idx" ON "newsletter_audience_recipients" USING btree ("email");--> statement-breakpoint +CREATE UNIQUE INDEX "newsletter_audience_recipients_run_version_email_unique" ON "newsletter_audience_recipients" USING btree ("run_id","snapshot_version","email");--> statement-breakpoint +CREATE INDEX "newsletter_audience_recipients_resend_status_email_idx" ON "newsletter_audience_recipients" USING btree ("run_id","resend_status","email");--> statement-breakpoint +CREATE INDEX "newsletter_audience_runs_created_at_idx" ON "newsletter_audience_runs" USING btree ("created_at");--> statement-breakpoint +CREATE INDEX "newsletter_audience_runs_created_by_idx" ON "newsletter_audience_runs" USING btree ("created_by_id");--> statement-breakpoint +CREATE INDEX "newsletter_audience_runs_status_idx" ON "newsletter_audience_runs" USING btree ("status");--> statement-breakpoint +CREATE INDEX "newsletter_audience_runs_resend_sync_job_idx" ON "newsletter_audience_runs" USING btree ("resend_sync_job_id"); \ No newline at end of file diff --git a/packages/db/migrations/meta/0277_snapshot.json b/packages/db/migrations/meta/0277_snapshot.json new file mode 100644 index 00000000000..034e5944644 --- /dev/null +++ b/packages/db/migrations/meta/0277_snapshot.json @@ -0,0 +1,18422 @@ +{ + "id": "96223929-f82c-455c-bab4-7b00f84d9180", + "prevId": "100afbc7-1a7f-4f05-9950-b2dbd6996c15", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.academy_certificate": { + "name": "academy_certificate", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "course_id": { + "name": "course_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "academy_cert_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "issued_at": { + "name": "issued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "certificate_number": { + "name": "certificate_number", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "academy_certificate_user_id_idx": { + "name": "academy_certificate_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_course_id_idx": { + "name": "academy_certificate_course_id_idx", + "columns": [ + { + "expression": "course_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_user_course_unique": { + "name": "academy_certificate_user_course_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "course_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_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.background_work_status": { + "name": "background_work_status", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "background_work_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "background_work_status_value", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "background_work_status_workspace_status_idx": { + "name": "background_work_status_workspace_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "background_work_status_workflow_status_idx": { + "name": "background_work_status_workflow_status_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "background_work_status_meta_child_ws_idx": { + "name": "background_work_status_meta_child_ws_idx", + "columns": [ + { + "expression": "(\"metadata\" ->> 'childWorkspaceId')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "background_work_status_meta_other_ws_idx": { + "name": "background_work_status_meta_other_ws_idx", + "columns": [ + { + "expression": "(\"metadata\" ->> 'otherWorkspaceId')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "background_work_status_workspace_id_workspace_id_fk": { + "name": "background_work_status_workspace_id_workspace_id_fk", + "tableFrom": "background_work_status", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "background_work_status_workflow_id_workflow_id_fk": { + "name": "background_work_status_workflow_id_workflow_id_fk", + "tableFrom": "background_work_status", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat": { + "name": "chat", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "customizations": { + "name": "customizations", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowed_emails": { + "name": "allowed_emails", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "output_configs": { + "name": "output_configs", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "include_thinking": { + "name": "include_thinking", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "include_tool_calls": { + "name": "include_tool_calls", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "identifier_idx": { + "name": "identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"chat\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_archived_at_partial_idx": { + "name": "chat_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"chat\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_chat_on_workflow_id_archived_at": { + "name": "idx_chat_on_workflow_id_archived_at", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_workflow_id_workflow_id_fk": { + "name": "chat_workflow_id_workflow_id_fk", + "tableFrom": "chat", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chat_user_id_user_id_fk": { + "name": "chat_user_id_user_id_fk", + "tableFrom": "chat", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_async_tool_calls": { + "name": "copilot_async_tool_calls", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "checkpoint_id": { + "name": "checkpoint_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "tool_call_id": { + "name": "tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "args": { + "name": "args", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "status": { + "name": "status", + "type": "copilot_async_tool_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "permission_decision": { + "name": "permission_decision", + "type": "copilot_tool_permission_decision", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "permission_decided_at": { + "name": "permission_decided_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "claimed_by": { + "name": "claimed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_async_tool_calls_run_id_idx": { + "name": "copilot_async_tool_calls_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_checkpoint_id_idx": { + "name": "copilot_async_tool_calls_checkpoint_id_idx", + "columns": [ + { + "expression": "checkpoint_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_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 + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'claude-3-7-sonnet-latest'" + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "preview_yaml": { + "name": "preview_yaml", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "plan_artifact": { + "name": "plan_artifact", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "resources": { + "name": "resources", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "auto_allowed_tools": { + "name": "auto_allowed_tools", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "pinned": { + "name": "pinned", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_chats_user_id_idx": { + "name": "copilot_chats_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_workflow_id_idx": { + "name": "copilot_chats_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_user_workflow_idx": { + "name": "copilot_chats_user_workflow_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_user_workspace_idx": { + "name": "copilot_chats_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_created_at_idx": { + "name": "copilot_chats_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_updated_at_idx": { + "name": "copilot_chats_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_workspace_created_at_id_idx": { + "name": "copilot_chats_workspace_created_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"created_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_user_workspace_deleted_partial_idx": { + "name": "copilot_chats_user_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_chats\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_chats_user_id_user_id_fk": { + "name": "copilot_chats_user_id_user_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_chats_workflow_id_workflow_id_fk": { + "name": "copilot_chats_workflow_id_workflow_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_chats_workspace_id_workspace_id_fk": { + "name": "copilot_chats_workspace_id_workspace_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_feedback": { + "name": "copilot_feedback", + "schema": "", + "columns": { + "feedback_id": { + "name": "feedback_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_query": { + "name": "user_query", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_response": { + "name": "agent_response", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_positive": { + "name": "is_positive", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "feedback": { + "name": "feedback", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_yaml": { + "name": "workflow_yaml", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_feedback_user_id_idx": { + "name": "copilot_feedback_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_chat_id_idx": { + "name": "copilot_feedback_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_user_chat_idx": { + "name": "copilot_feedback_user_chat_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_is_positive_idx": { + "name": "copilot_feedback_is_positive_idx", + "columns": [ + { + "expression": "is_positive", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_created_at_idx": { + "name": "copilot_feedback_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_feedback_user_id_user_id_fk": { + "name": "copilot_feedback_user_id_user_id_fk", + "tableFrom": "copilot_feedback", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_feedback_chat_id_copilot_chats_id_fk": { + "name": "copilot_feedback_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_feedback", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_messages": { + "name": "copilot_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "stream_id": { + "name": "stream_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parent_message_id": { + "name": "parent_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tokens_in": { + "name": "tokens_in", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "tokens_out": { + "name": "tokens_out", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_messages_chat_message_unique": { + "name": "copilot_messages_chat_message_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_chat_created_at_idx": { + "name": "copilot_messages_chat_created_at_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_chat_seq_idx": { + "name": "copilot_messages_chat_seq_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "seq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_chat_stream_idx": { + "name": "copilot_messages_chat_stream_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "stream_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"stream_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_user_created_at_idx": { + "name": "copilot_messages_user_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"role\" = 'user' AND \"copilot_messages\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_messages_chat_id_copilot_chats_id_fk": { + "name": "copilot_messages_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_messages", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_run_checkpoints": { + "name": "copilot_run_checkpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "pending_tool_call_id": { + "name": "pending_tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conversation_snapshot": { + "name": "conversation_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "agent_state": { + "name": "agent_state", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "provider_request": { + "name": "provider_request", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_run_checkpoints_run_id_idx": { + "name": "copilot_run_checkpoints_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_run_checkpoints_pending_tool_call_id_idx": { + "name": "copilot_run_checkpoints_pending_tool_call_id_idx", + "columns": [ + { + "expression": "pending_tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_run_checkpoints_run_pending_tool_unique": { + "name": "copilot_run_checkpoints_run_pending_tool_unique", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pending_tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_run_checkpoints_run_id_copilot_runs_id_fk": { + "name": "copilot_run_checkpoints_run_id_copilot_runs_id_fk", + "tableFrom": "copilot_run_checkpoints", + "tableTo": "copilot_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_runs": { + "name": "copilot_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_run_id": { + "name": "parent_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stream_id": { + "name": "stream_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent": { + "name": "agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "copilot_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "request_context": { + "name": "request_context", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "copilot_runs_execution_id_idx": { + "name": "copilot_runs_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_parent_run_id_idx": { + "name": "copilot_runs_parent_run_id_idx", + "columns": [ + { + "expression": "parent_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_chat_id_idx": { + "name": "copilot_runs_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_user_id_idx": { + "name": "copilot_runs_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_workflow_id_idx": { + "name": "copilot_runs_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_workspace_id_idx": { + "name": "copilot_runs_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_status_idx": { + "name": "copilot_runs_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_chat_execution_idx": { + "name": "copilot_runs_chat_execution_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_execution_started_at_idx": { + "name": "copilot_runs_execution_started_at_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_workspace_completed_at_id_idx": { + "name": "copilot_runs_workspace_completed_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"completed_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_stream_id_unique": { + "name": "copilot_runs_stream_id_unique", + "columns": [ + { + "expression": "stream_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_runs_chat_id_copilot_chats_id_fk": { + "name": "copilot_runs_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_user_id_user_id_fk": { + "name": "copilot_runs_user_id_user_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_workflow_id_workflow_id_fk": { + "name": "copilot_runs_workflow_id_workflow_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_workspace_id_workspace_id_fk": { + "name": "copilot_runs_workspace_id_workspace_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_workflow_read_hashes": { + "name": "copilot_workflow_read_hashes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_workflow_read_hashes_chat_id_idx": { + "name": "copilot_workflow_read_hashes_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_workflow_read_hashes_workflow_id_idx": { + "name": "copilot_workflow_read_hashes_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_workflow_read_hashes_chat_workflow_unique": { + "name": "copilot_workflow_read_hashes_chat_workflow_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_workflow_read_hashes_chat_id_copilot_chats_id_fk": { + "name": "copilot_workflow_read_hashes_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_workflow_read_hashes", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_workflow_read_hashes_workflow_id_workflow_id_fk": { + "name": "copilot_workflow_read_hashes_workflow_id_workflow_id_fk", + "tableFrom": "copilot_workflow_read_hashes", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credential": { + "name": "credential", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "credential_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "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.custom_block": { + "name": "custom_block", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "icon_url": { + "name": "icon_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "inputs": { + "name": "inputs", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "outputs": { + "name": "outputs", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "custom_block_organization_id_idx": { + "name": "custom_block_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_block_workflow_id_idx": { + "name": "custom_block_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_block_organization_type_unique": { + "name": "custom_block_organization_type_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "custom_block_organization_id_organization_id_fk": { + "name": "custom_block_organization_id_organization_id_fk", + "tableFrom": "custom_block", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "custom_block_workflow_id_workflow_id_fk": { + "name": "custom_block_workflow_id_workflow_id_fk", + "tableFrom": "custom_block", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "custom_block_created_by_user_id_fk": { + "name": "custom_block_created_by_user_id_fk", + "tableFrom": "custom_block", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.custom_tools": { + "name": "custom_tools", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schema": { + "name": "schema", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "custom_tools_workspace_id_idx": { + "name": "custom_tools_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_tools_workspace_title_unique": { + "name": "custom_tools_workspace_title_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "title", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "custom_tools_workspace_id_workspace_id_fk": { + "name": "custom_tools_workspace_id_workspace_id_fk", + "tableFrom": "custom_tools", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "custom_tools_user_id_user_id_fk": { + "name": "custom_tools_user_id_user_id_fk", + "tableFrom": "custom_tools", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.data_drain_runs": { + "name": "data_drain_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "drain_id": { + "name": "drain_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "data_drain_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "trigger": { + "name": "trigger", + "type": "data_drain_run_trigger", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "rows_exported": { + "name": "rows_exported", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "bytes_written": { + "name": "bytes_written", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cursor_before": { + "name": "cursor_before", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cursor_after": { + "name": "cursor_after", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "locators": { + "name": "locators", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + } + }, + "indexes": { + "data_drain_runs_drain_started_idx": { + "name": "data_drain_runs_drain_started_idx", + "columns": [ + { + "expression": "drain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "data_drain_runs_drain_id_data_drains_id_fk": { + "name": "data_drain_runs_drain_id_data_drains_id_fk", + "tableFrom": "data_drain_runs", + "tableTo": "data_drains", + "columnsFrom": ["drain_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.data_drains": { + "name": "data_drains", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "data_drain_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "destination_type": { + "name": "destination_type", + "type": "data_drain_destination", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "destination_config": { + "name": "destination_config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "destination_credentials": { + "name": "destination_credentials", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schedule_cadence": { + "name": "schedule_cadence", + "type": "data_drain_cadence", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "cursor": { + "name": "cursor", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_success_at": { + "name": "last_success_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "data_drains_org_idx": { + "name": "data_drains_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "data_drains_due_idx": { + "name": "data_drains_due_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "data_drains_org_name_unique": { + "name": "data_drains_org_name_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "data_drains_organization_id_organization_id_fk": { + "name": "data_drains_organization_id_organization_id_fk", + "tableFrom": "data_drains", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "data_drains_created_by_user_id_fk": { + "name": "data_drains_created_by_user_id_fk", + "tableFrom": "data_drains", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.docs_embeddings": { + "name": "docs_embeddings", + "schema": "", + "columns": { + "chunk_id": { + "name": "chunk_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chunk_text": { + "name": "chunk_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_document": { + "name": "source_document", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_link": { + "name": "source_link", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "header_text": { + "name": "header_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "header_level": { + "name": "header_level", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": true + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "chunk_text_tsv": { + "name": "chunk_text_tsv", + "type": "tsvector", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "to_tsvector('english', \"docs_embeddings\".\"chunk_text\")", + "type": "stored" + } + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "docs_emb_source_document_idx": { + "name": "docs_emb_source_document_idx", + "columns": [ + { + "expression": "source_document", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_header_level_idx": { + "name": "docs_emb_header_level_idx", + "columns": [ + { + "expression": "header_level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_source_header_idx": { + "name": "docs_emb_source_header_idx", + "columns": [ + { + "expression": "source_document", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "header_level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_model_idx": { + "name": "docs_emb_model_idx", + "columns": [ + { + "expression": "embedding_model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_created_at_idx": { + "name": "docs_emb_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_embedding_vector_hnsw_idx": { + "name": "docs_embedding_vector_hnsw_idx", + "columns": [ + { + "expression": "embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "docs_emb_metadata_gin_idx": { + "name": "docs_emb_metadata_gin_idx", + "columns": [ + { + "expression": "metadata", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "docs_emb_chunk_text_fts_idx": { + "name": "docs_emb_chunk_text_fts_idx", + "columns": [ + { + "expression": "chunk_text_tsv", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "docs_embedding_not_null_check": { + "name": "docs_embedding_not_null_check", + "value": "\"embedding\" IS NOT NULL" + }, + "docs_header_level_check": { + "name": "docs_header_level_check", + "value": "\"header_level\" >= 1 AND \"header_level\" <= 6" + } + }, + "isRLSEnabled": false + }, + "public.document": { + "name": "document", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_url": { + "name": "file_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "file_size": { + "name": "file_size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chunk_count": { + "name": "chunk_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "character_count": { + "name": "character_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "processing_status": { + "name": "processing_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "processing_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_by": { + "name": "uploaded_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "doc_kb_id_idx": { + "name": "doc_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_filename_idx": { + "name": "doc_filename_idx", + "columns": [ + { + "expression": "filename", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_processing_status_idx": { + "name": "doc_processing_status_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "processing_status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_connector_external_id_idx": { + "name": "doc_connector_external_id_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"document\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_connector_id_idx": { + "name": "doc_connector_id_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_storage_key_idx": { + "name": "doc_storage_key_idx", + "columns": [ + { + "expression": "storage_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"storage_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_archived_at_partial_idx": { + "name": "doc_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_deleted_at_partial_idx": { + "name": "doc_deleted_at_partial_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag1_idx": { + "name": "doc_tag1_idx", + "columns": [ + { + "expression": "tag1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag2_idx": { + "name": "doc_tag2_idx", + "columns": [ + { + "expression": "tag2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag3_idx": { + "name": "doc_tag3_idx", + "columns": [ + { + "expression": "tag3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag4_idx": { + "name": "doc_tag4_idx", + "columns": [ + { + "expression": "tag4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag5_idx": { + "name": "doc_tag5_idx", + "columns": [ + { + "expression": "tag5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag6_idx": { + "name": "doc_tag6_idx", + "columns": [ + { + "expression": "tag6", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag7_idx": { + "name": "doc_tag7_idx", + "columns": [ + { + "expression": "tag7", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number1_idx": { + "name": "doc_number1_idx", + "columns": [ + { + "expression": "number1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number2_idx": { + "name": "doc_number2_idx", + "columns": [ + { + "expression": "number2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number3_idx": { + "name": "doc_number3_idx", + "columns": [ + { + "expression": "number3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number4_idx": { + "name": "doc_number4_idx", + "columns": [ + { + "expression": "number4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number5_idx": { + "name": "doc_number5_idx", + "columns": [ + { + "expression": "number5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_date1_idx": { + "name": "doc_date1_idx", + "columns": [ + { + "expression": "date1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_date2_idx": { + "name": "doc_date2_idx", + "columns": [ + { + "expression": "date2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean1_idx": { + "name": "doc_boolean1_idx", + "columns": [ + { + "expression": "boolean1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean2_idx": { + "name": "doc_boolean2_idx", + "columns": [ + { + "expression": "boolean2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean3_idx": { + "name": "doc_boolean3_idx", + "columns": [ + { + "expression": "boolean3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "document_knowledge_base_id_knowledge_base_id_fk": { + "name": "document_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "document", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "document_connector_id_knowledge_connector_id_fk": { + "name": "document_connector_id_knowledge_connector_id_fk", + "tableFrom": "document", + "tableTo": "knowledge_connector", + "columnsFrom": ["connector_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "document_uploaded_by_user_id_fk": { + "name": "document_uploaded_by_user_id_fk", + "tableFrom": "document", + "tableTo": "user", + "columnsFrom": ["uploaded_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.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.folder": { + "name": "folder", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "folder_resource_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "folder_user_idx": { + "name": "folder_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "folder_workspace_resource_parent_idx": { + "name": "folder_workspace_resource_parent_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "folder_parent_sort_idx": { + "name": "folder_parent_sort_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "folder_deleted_at_idx": { + "name": "folder_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "folder_workspace_deleted_partial_idx": { + "name": "folder_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"folder\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "folder_workspace_resource_parent_name_active_unique": { + "name": "folder_workspace_resource_parent_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"parent_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"folder\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "folder_user_id_user_id_fk": { + "name": "folder_user_id_user_id_fk", + "tableFrom": "folder", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "folder_workspace_id_workspace_id_fk": { + "name": "folder_workspace_id_workspace_id_fk", + "tableFrom": "folder", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "folder_parent_id_folder_id_fk": { + "name": "folder_parent_id_folder_id_fk", + "tableFrom": "folder", + "tableTo": "folder", + "columnsFrom": ["parent_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.idempotency_key": { + "name": "idempotency_key", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "result": { + "name": "result", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idempotency_key_created_at_idx": { + "name": "idempotency_key_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation": { + "name": "invitation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "invitation_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'organization'" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "membership_intent": { + "name": "membership_intent", + "type": "invitation_membership_intent", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'internal'" + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "invitation_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invitation_email_idx": { + "name": "invitation_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_organization_id_idx": { + "name": "invitation_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_status_idx": { + "name": "invitation_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_pending_email_org_unique": { + "name": "invitation_pending_email_org_unique", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"invitation\".\"status\" = 'pending' AND \"invitation\".\"organization_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitation_inviter_id_user_id_fk": { + "name": "invitation_inviter_id_user_id_fk", + "tableFrom": "invitation", + "tableTo": "user", + "columnsFrom": ["inviter_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_organization_id_organization_id_fk": { + "name": "invitation_organization_id_organization_id_fk", + "tableFrom": "invitation", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "invitation_token_unique": { + "name": "invitation_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation_workspace_grant": { + "name": "invitation_workspace_grant", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "invitation_id": { + "name": "invitation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission": { + "name": "permission", + "type": "permission_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invitation_workspace_grant_unique": { + "name": "invitation_workspace_grant_unique", + "columns": [ + { + "expression": "invitation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_workspace_grant_workspace_id_idx": { + "name": "invitation_workspace_grant_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitation_workspace_grant_invitation_id_invitation_id_fk": { + "name": "invitation_workspace_grant_invitation_id_invitation_id_fk", + "tableFrom": "invitation_workspace_grant", + "tableTo": "invitation", + "columnsFrom": ["invitation_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_workspace_grant_workspace_id_workspace_id_fk": { + "name": "invitation_workspace_grant_workspace_id_workspace_id_fk", + "tableFrom": "invitation_workspace_grant", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.job_execution_logs": { + "name": "job_execution_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "schedule_id": { + "name": "schedule_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_duration_ms": { + "name": "total_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "execution_data": { + "name": "execution_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "cost": { + "name": "cost", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "job_execution_logs_schedule_id_idx": { + "name": "job_execution_logs_schedule_id_idx", + "columns": [ + { + "expression": "schedule_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_workspace_started_at_idx": { + "name": "job_execution_logs_workspace_started_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_workspace_ended_at_id_idx": { + "name": "job_execution_logs_workspace_ended_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"ended_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_execution_id_unique": { + "name": "job_execution_logs_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_trigger_idx": { + "name": "job_execution_logs_trigger_idx", + "columns": [ + { + "expression": "trigger", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "job_execution_logs_schedule_id_workflow_schedule_id_fk": { + "name": "job_execution_logs_schedule_id_workflow_schedule_id_fk", + "tableFrom": "job_execution_logs", + "tableTo": "workflow_schedule", + "columnsFrom": ["schedule_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "job_execution_logs_workspace_id_workspace_id_fk": { + "name": "job_execution_logs_workspace_id_workspace_id_fk", + "tableFrom": "job_execution_logs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_base": { + "name": "knowledge_base", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "embedding_dimension": { + "name": "embedding_dimension", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1536 + }, + "chunking_config": { + "name": "chunking_config", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{\"maxSize\": 1024, \"minSize\": 1, \"overlap\": 200}'" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "kb_user_id_idx": { + "name": "kb_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_workspace_id_idx": { + "name": "kb_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_user_workspace_idx": { + "name": "kb_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_folder_id_idx": { + "name": "kb_folder_id_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_deleted_at_idx": { + "name": "kb_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_workspace_deleted_partial_idx": { + "name": "kb_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_base\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_workspace_name_active_unique": { + "name": "kb_workspace_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"knowledge_base\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_base_user_id_user_id_fk": { + "name": "knowledge_base_user_id_user_id_fk", + "tableFrom": "knowledge_base", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "knowledge_base_workspace_id_workspace_id_fk": { + "name": "knowledge_base_workspace_id_workspace_id_fk", + "tableFrom": "knowledge_base", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "knowledge_base_folder_id_folder_id_fk": { + "name": "knowledge_base_folder_id_folder_id_fk", + "tableFrom": "knowledge_base", + "tableTo": "folder", + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_base_tag_definitions": { + "name": "knowledge_base_tag_definitions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tag_slot": { + "name": "tag_slot", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "field_type": { + "name": "field_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "kb_tag_definitions_kb_slot_idx": { + "name": "kb_tag_definitions_kb_slot_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tag_slot", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_tag_definitions_kb_display_name_idx": { + "name": "kb_tag_definitions_kb_display_name_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "display_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_tag_definitions_kb_id_idx": { + "name": "kb_tag_definitions_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_base_tag_definitions_knowledge_base_id_knowledge_base_id_fk": { + "name": "knowledge_base_tag_definitions_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "knowledge_base_tag_definitions", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_connector": { + "name": "knowledge_connector", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connector_type": { + "name": "connector_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_api_key": { + "name": "encrypted_api_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_config": { + "name": "source_config", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "sync_mode": { + "name": "sync_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'full'" + }, + "sync_interval_minutes": { + "name": "sync_interval_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1440 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "last_sync_at": { + "name": "last_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_sync_error": { + "name": "last_sync_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_sync_doc_count": { + "name": "last_sync_doc_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "next_sync_at": { + "name": "next_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "consecutive_failures": { + "name": "consecutive_failures", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "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.newsletter_audience_recipients": { + "name": "newsletter_audience_recipients", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "inclusion_reason": { + "name": "inclusion_reason", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "snapshot_version": { + "name": "snapshot_version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "resend_contact_id": { + "name": "resend_contact_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resend_status": { + "name": "resend_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "error": { + "name": "error", + "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": { + "newsletter_audience_recipients_run_id_idx": { + "name": "newsletter_audience_recipients_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "newsletter_audience_recipients_user_id_idx": { + "name": "newsletter_audience_recipients_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "newsletter_audience_recipients_email_idx": { + "name": "newsletter_audience_recipients_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "newsletter_audience_recipients_run_version_email_unique": { + "name": "newsletter_audience_recipients_run_version_email_unique", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "snapshot_version", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "newsletter_audience_recipients_resend_status_email_idx": { + "name": "newsletter_audience_recipients_resend_status_email_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resend_status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "newsletter_audience_recipients_run_id_newsletter_audience_runs_id_fk": { + "name": "newsletter_audience_recipients_run_id_newsletter_audience_runs_id_fk", + "tableFrom": "newsletter_audience_recipients", + "tableTo": "newsletter_audience_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "newsletter_audience_recipients_user_id_user_id_fk": { + "name": "newsletter_audience_recipients_user_id_user_id_fk", + "tableFrom": "newsletter_audience_recipients", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.newsletter_audience_runs": { + "name": "newsletter_audience_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_by_id": { + "name": "created_by_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "criteria": { + "name": "criteria", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "total_matched": { + "name": "total_matched", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "excluded_banned": { + "name": "excluded_banned", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "excluded_unverified": { + "name": "excluded_unverified", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "excluded_unsubscribed": { + "name": "excluded_unsubscribed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "excluded_suppressed": { + "name": "excluded_suppressed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "final_recipient_count": { + "name": "final_recipient_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "sample_recipients": { + "name": "sample_recipients", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "resend_segment_id": { + "name": "resend_segment_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resend_segment_name": { + "name": "resend_segment_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resend_synced_at": { + "name": "resend_synced_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "snapshot_version": { + "name": "snapshot_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "resend_sync_attempt": { + "name": "resend_sync_attempt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "resend_sync_job_id": { + "name": "resend_sync_job_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "finalized_at": { + "name": "finalized_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": { + "newsletter_audience_runs_created_at_idx": { + "name": "newsletter_audience_runs_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "newsletter_audience_runs_created_by_idx": { + "name": "newsletter_audience_runs_created_by_idx", + "columns": [ + { + "expression": "created_by_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "newsletter_audience_runs_status_idx": { + "name": "newsletter_audience_runs_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "newsletter_audience_runs_resend_sync_job_idx": { + "name": "newsletter_audience_runs_resend_sync_job_idx", + "columns": [ + { + "expression": "resend_sync_job_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "newsletter_audience_runs_created_by_id_user_id_fk": { + "name": "newsletter_audience_runs_created_by_id_user_id_fk", + "tableFrom": "newsletter_audience_runs", + "tableTo": "user", + "columnsFrom": ["created_by_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization": { + "name": "organization", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "session_policy_settings": { + "name": "session_policy_settings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "security_policy_version": { + "name": "security_policy_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "whitelabel_settings": { + "name": "whitelabel_settings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "data_retention_settings": { + "name": "data_retention_settings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "org_usage_limit": { + "name": "org_usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "limit_notifications": { + "name": "limit_notifications", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "departed_member_usage": { + "name": "departed_member_usage", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "credit_balance": { + "name": "credit_balance", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_member_usage_limit": { + "name": "organization_member_usage_limit", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "usage_limit": { + "name": "usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "set_by": { + "name": "set_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "org_member_usage_limit_org_user_unique": { + "name": "org_member_usage_limit_org_user_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "org_member_usage_limit_organization_id_idx": { + "name": "org_member_usage_limit_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "organization_member_usage_limit_organization_id_organization_id_fk": { + "name": "organization_member_usage_limit_organization_id_organization_id_fk", + "tableFrom": "organization_member_usage_limit", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_member_usage_limit_user_id_user_id_fk": { + "name": "organization_member_usage_limit_user_id_user_id_fk", + "tableFrom": "organization_member_usage_limit", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_member_usage_limit_set_by_user_id_fk": { + "name": "organization_member_usage_limit_set_by_user_id_fk", + "tableFrom": "organization_member_usage_limit", + "tableTo": "user", + "columnsFrom": ["set_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.outbox_event": { + "name": "outbox_event", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "max_attempts": { + "name": "max_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10 + }, + "available_at": { + "name": "available_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "locked_at": { + "name": "locked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "outbox_event_status_available_idx": { + "name": "outbox_event_status_available_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "available_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "outbox_event_locked_at_idx": { + "name": "outbox_event_locked_at_idx", + "columns": [ + { + "expression": "locked_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "outbox_event_type_created_idx": { + "name": "outbox_event_type_created_idx", + "columns": [ + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.paused_executions": { + "name": "paused_executions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_snapshot": { + "name": "execution_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "pause_points": { + "name": "pause_points", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "total_pause_count": { + "name": "total_pause_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "resumed_count": { + "name": "resumed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "automatic_resume_retry_count": { + "name": "automatic_resume_retry_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'paused'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "paused_at": { + "name": "paused_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "next_resume_at": { + "name": "next_resume_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "paused_executions_workflow_id_idx": { + "name": "paused_executions_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paused_executions_status_idx": { + "name": "paused_executions_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paused_executions_execution_id_unique": { + "name": "paused_executions_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paused_executions_next_resume_at_idx": { + "name": "paused_executions_next_resume_at_idx", + "columns": [ + { + "expression": "next_resume_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "status = 'paused' AND next_resume_at IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "paused_executions_workflow_id_workflow_id_fk": { + "name": "paused_executions_workflow_id_workflow_id_fk", + "tableFrom": "paused_executions", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pending_credential_draft": { + "name": "pending_credential_draft", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pending_draft_user_provider_ws": { + "name": "pending_draft_user_provider_ws", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pending_credential_draft_user_id_user_id_fk": { + "name": "pending_credential_draft_user_id_user_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pending_credential_draft_workspace_id_workspace_id_fk": { + "name": "pending_credential_draft_workspace_id_workspace_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pending_credential_draft_credential_id_credential_id_fk": { + "name": "pending_credential_draft_credential_id_credential_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "credential", + "columnsFrom": ["credential_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission_group": { + "name": "permission_group", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "permission_group_created_by_idx": { + "name": "permission_group_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_organization_name_unique": { + "name": "permission_group_organization_name_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_organization_default_unique": { + "name": "permission_group_organization_default_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "is_default = true", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_group_organization_id_organization_id_fk": { + "name": "permission_group_organization_id_organization_id_fk", + "tableFrom": "permission_group", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_created_by_user_id_fk": { + "name": "permission_group_created_by_user_id_fk", + "tableFrom": "permission_group", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission_group_member": { + "name": "permission_group_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "permission_group_id": { + "name": "permission_group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "assigned_by": { + "name": "assigned_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assigned_at": { + "name": "assigned_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permission_group_member_group_id_idx": { + "name": "permission_group_member_group_id_idx", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_member_group_user_unique": { + "name": "permission_group_member_group_user_unique", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_member_organization_user_idx": { + "name": "permission_group_member_organization_user_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_group_member_permission_group_id_permission_group_id_fk": { + "name": "permission_group_member_permission_group_id_permission_group_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "permission_group", + "columnsFrom": ["permission_group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_member_organization_id_organization_id_fk": { + "name": "permission_group_member_organization_id_organization_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_member_user_id_user_id_fk": { + "name": "permission_group_member_user_id_user_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_member_assigned_by_user_id_fk": { + "name": "permission_group_member_assigned_by_user_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "user", + "columnsFrom": ["assigned_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission_group_workspace": { + "name": "permission_group_workspace", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "permission_group_id": { + "name": "permission_group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permission_group_workspace_workspace_id_idx": { + "name": "permission_group_workspace_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_workspace_group_workspace_unique": { + "name": "permission_group_workspace_group_workspace_unique", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_group_workspace_permission_group_id_permission_group_id_fk": { + "name": "permission_group_workspace_permission_group_id_permission_group_id_fk", + "tableFrom": "permission_group_workspace", + "tableTo": "permission_group", + "columnsFrom": ["permission_group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_workspace_workspace_id_workspace_id_fk": { + "name": "permission_group_workspace_workspace_id_workspace_id_fk", + "tableFrom": "permission_group_workspace", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_workspace_organization_id_organization_id_fk": { + "name": "permission_group_workspace_organization_id_organization_id_fk", + "tableFrom": "permission_group_workspace", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permissions": { + "name": "permissions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission_type": { + "name": "permission_type", + "type": "permission_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permissions_user_id_idx": { + "name": "permissions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_entity_idx": { + "name": "permissions_entity_idx", + "columns": [ + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_type_idx": { + "name": "permissions_user_entity_type_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_permission_idx": { + "name": "permissions_user_entity_permission_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "permission_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_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.pinned_item": { + "name": "pinned_item", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pinned_at": { + "name": "pinned_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pinned_item_user_workspace_idx": { + "name": "pinned_item_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pinned_item_resource_idx": { + "name": "pinned_item_resource_idx", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pinned_item_user_resource_unique": { + "name": "pinned_item_user_resource_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pinned_item_user_id_user_id_fk": { + "name": "pinned_item_user_id_user_id_fk", + "tableFrom": "pinned_item", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pinned_item_workspace_id_workspace_id_fk": { + "name": "pinned_item_workspace_id_workspace_id_fk", + "tableFrom": "pinned_item", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.public_share": { + "name": "public_share", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowed_emails": { + "name": "allowed_emails", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "public_share_token_unique": { + "name": "public_share_token_unique", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "public_share_resource_unique": { + "name": "public_share_resource_unique", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "public_share_resource_id_idx": { + "name": "public_share_resource_id_idx", + "columns": [ + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "public_share_workspace_id_idx": { + "name": "public_share_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "public_share_workspace_id_workspace_id_fk": { + "name": "public_share_workspace_id_workspace_id_fk", + "tableFrom": "public_share", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "public_share_created_by_user_id_fk": { + "name": "public_share_created_by_user_id_fk", + "tableFrom": "public_share", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rate_limit_bucket": { + "name": "rate_limit_bucket", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tokens": { + "name": "tokens", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "last_refill_at": { + "name": "last_refill_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.resume_queue": { + "name": "resume_queue", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "paused_execution_id": { + "name": "paused_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_execution_id": { + "name": "parent_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "new_execution_id": { + "name": "new_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "context_id": { + "name": "context_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resume_input": { + "name": "resume_input", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "queued_at": { + "name": "queued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "failure_reason": { + "name": "failure_reason", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "resume_queue_parent_status_idx": { + "name": "resume_queue_parent_status_idx", + "columns": [ + { + "expression": "parent_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "resume_queue_new_execution_idx": { + "name": "resume_queue_new_execution_idx", + "columns": [ + { + "expression": "new_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "resume_queue_paused_execution_id_paused_executions_id_fk": { + "name": "resume_queue_paused_execution_id_paused_executions_id_fk", + "tableFrom": "resume_queue", + "tableTo": "paused_executions", + "columnsFrom": ["paused_execution_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.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 + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "copilot_enabled_models": { + "name": "copilot_enabled_models", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "copilot_auto_allowed_tools": { + "name": "copilot_auto_allowed_tools", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "last_active_workspace_id": { + "name": "last_active_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "settings_user_id_user_id_fk": { + "name": "settings_user_id_user_id_fk", + "tableFrom": "settings", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "settings_user_id_unique": { + "name": "settings_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sim_trigger_state": { + "name": "sim_trigger_state", + "schema": "", + "columns": { + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_key": { + "name": "scope_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "last_fired_at": { + "name": "last_fired_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "sim_trigger_state_workflow_id_workflow_id_fk": { + "name": "sim_trigger_state_workflow_id_workflow_id_fk", + "tableFrom": "sim_trigger_state", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "sim_trigger_state_workflow_id_block_id_scope_key_pk": { + "name": "sim_trigger_state_workflow_id_block_id_scope_key_pk", + "columns": ["workflow_id", "block_id", "scope_key"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skill": { + "name": "skill", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "skill_workspace_name_unique": { + "name": "skill_workspace_name_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skill_workspace_id_workspace_id_fk": { + "name": "skill_workspace_id_workspace_id_fk", + "tableFrom": "skill", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "skill_user_id_user_id_fk": { + "name": "skill_user_id_user_id_fk", + "tableFrom": "skill", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skill_member": { + "name": "skill_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "skill_id": { + "name": "skill_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "skill_member_user_id_idx": { + "name": "skill_member_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "skill_member_unique": { + "name": "skill_member_unique", + "columns": [ + { + "expression": "skill_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skill_member_skill_id_skill_id_fk": { + "name": "skill_member_skill_id_skill_id_fk", + "tableFrom": "skill_member", + "tableTo": "skill", + "columnsFrom": ["skill_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "skill_member_user_id_user_id_fk": { + "name": "skill_member_user_id_user_id_fk", + "tableFrom": "skill_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "skill_member_invited_by_user_id_fk": { + "name": "skill_member_invited_by_user_id_fk", + "tableFrom": "skill_member", + "tableTo": "user", + "columnsFrom": ["invited_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sso_domain": { + "name": "sso_domain", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "verification_token": { + "name": "verification_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "verified_at": { + "name": "verified_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sso_domain_organization_id_idx": { + "name": "sso_domain_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_domain_domain_idx": { + "name": "sso_domain_domain_idx", + "columns": [ + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_domain_org_domain_unique": { + "name": "sso_domain_org_domain_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_domain_verified_unique": { + "name": "sso_domain_verified_unique", + "columns": [ + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "status = 'verified'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sso_domain_organization_id_organization_id_fk": { + "name": "sso_domain_organization_id_organization_id_fk", + "tableFrom": "sso_domain", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sso_domain_created_by_user_id_fk": { + "name": "sso_domain_created_by_user_id_fk", + "tableFrom": "sso_domain", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sso_provider": { + "name": "sso_provider", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "oidc_config": { + "name": "oidc_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "saml_config": { + "name": "saml_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "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_jobs": { + "name": "table_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "rows_processed": { + "name": "rows_processed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "table_jobs_one_active_per_table": { + "name": "table_jobs_one_active_per_table", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"table_jobs\".\"status\" = 'running' AND \"table_jobs\".\"type\" <> 'export'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_jobs_watchdog_idx": { + "name": "table_jobs_watchdog_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_jobs_table_started_idx": { + "name": "table_jobs_table_started_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_jobs_table_id_user_table_definitions_id_fk": { + "name": "table_jobs_table_id_user_table_definitions_id_fk", + "tableFrom": "table_jobs", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_jobs_workspace_id_workspace_id_fk": { + "name": "table_jobs_workspace_id_workspace_id_fk", + "tableFrom": "table_jobs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.table_row_executions": { + "name": "table_row_executions", + "schema": "", + "columns": { + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "job_id": { + "name": "job_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "running_block_ids": { + "name": "running_block_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "block_errors": { + "name": "block_errors", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "enrichment_details": { + "name": "enrichment_details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "table_row_executions_table_status_idx": { + "name": "table_row_executions_table_status_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"table_row_executions\".\"status\" IN ('queued', 'running', 'pending')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_row_executions_execution_id_idx": { + "name": "table_row_executions_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"table_row_executions\".\"execution_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_row_executions_table_group_idx": { + "name": "table_row_executions_table_group_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_row_executions_table_id_user_table_definitions_id_fk": { + "name": "table_row_executions_table_id_user_table_definitions_id_fk", + "tableFrom": "table_row_executions", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_row_executions_row_id_user_table_rows_id_fk": { + "name": "table_row_executions_row_id_user_table_rows_id_fk", + "tableFrom": "table_row_executions", + "tableTo": "user_table_rows", + "columnsFrom": ["row_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "table_row_executions_row_id_group_id_pk": { + "name": "table_row_executions_row_id_group_id_pk", + "columns": ["row_id", "group_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.table_run_dispatches": { + "name": "table_run_dispatches", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "cursor": { + "name": "cursor", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "limit": { + "name": "limit", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "processed_count": { + "name": "processed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_manual_run": { + "name": "is_manual_run", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "triggered_by_user_id": { + "name": "triggered_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "requested_at": { + "name": "requested_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "table_run_dispatches_active_idx": { + "name": "table_run_dispatches_active_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_run_dispatches_watchdog_idx": { + "name": "table_run_dispatches_watchdog_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "requested_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_run_dispatches_table_id_user_table_definitions_id_fk": { + "name": "table_run_dispatches_table_id_user_table_definitions_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_run_dispatches_workspace_id_workspace_id_fk": { + "name": "table_run_dispatches_workspace_id_workspace_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_run_dispatches_triggered_by_user_id_user_id_fk": { + "name": "table_run_dispatches_triggered_by_user_id_user_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "user", + "columnsFrom": ["triggered_by_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.table_views": { + "name": "table_views", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "table_views_table_created_idx": { + "name": "table_views_table_created_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_views_table_default_unique": { + "name": "table_views_table_default_unique", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "is_default = true", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_views_table_id_user_table_definitions_id_fk": { + "name": "table_views_table_id_user_table_definitions_id_fk", + "tableFrom": "table_views", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_views_workspace_id_workspace_id_fk": { + "name": "table_views_workspace_id_workspace_id_fk", + "tableFrom": "table_views", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_views_created_by_user_id_fk": { + "name": "table_views_created_by_user_id_fk", + "tableFrom": "table_views", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.usage_log": { + "name": "usage_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "usage_log_category", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "usage_log_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "cost": { + "name": "cost", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "event_key": { + "name": "event_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "billing_entity_type": { + "name": "billing_entity_type", + "type": "billing_entity_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "billing_entity_id": { + "name": "billing_entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "billing_period_start": { + "name": "billing_period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "billing_period_end": { + "name": "billing_period_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "usage_log_user_created_at_idx": { + "name": "usage_log_user_created_at_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_source_idx": { + "name": "usage_log_source_idx", + "columns": [ + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_workspace_id_idx": { + "name": "usage_log_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_workflow_id_idx": { + "name": "usage_log_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_event_key_unique": { + "name": "usage_log_event_key_unique", + "columns": [ + { + "expression": "event_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"usage_log\".\"event_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_billing_entity_period_idx": { + "name": "usage_log_billing_entity_period_idx", + "columns": [ + { + "expression": "billing_entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_period_start", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_period_end", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_log\".\"billing_entity_type\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_billing_period_cost_idx": { + "name": "usage_log_billing_period_cost_idx", + "columns": [ + { + "expression": "billing_entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_period_start", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_period_end", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_log\".\"billing_entity_type\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_workspace_created_at_idx": { + "name": "usage_log_workspace_created_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_execution_id_idx": { + "name": "usage_log_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "usage_log_user_id_user_id_fk": { + "name": "usage_log_user_id_user_id_fk", + "tableFrom": "usage_log", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "usage_log_workspace_id_workspace_id_fk": { + "name": "usage_log_workspace_id_workspace_id_fk", + "tableFrom": "usage_log", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "usage_log_workflow_id_workflow_id_fk": { + "name": "usage_log_workflow_id_workflow_id_fk", + "tableFrom": "usage_log", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "usage_log_billing_scope_all_or_none": { + "name": "usage_log_billing_scope_all_or_none", + "value": "(\n (\"usage_log\".\"billing_entity_type\" IS NULL AND \"usage_log\".\"billing_entity_id\" IS NULL AND \"usage_log\".\"billing_period_start\" IS NULL AND \"usage_log\".\"billing_period_end\" IS NULL)\n OR\n (\"usage_log\".\"billing_entity_type\" IS NOT NULL AND \"usage_log\".\"billing_entity_id\" IS NOT NULL AND \"usage_log\".\"billing_period_start\" IS NOT NULL AND \"usage_log\".\"billing_period_end\" IS NOT NULL AND \"usage_log\".\"billing_period_start\" < \"usage_log\".\"billing_period_end\")\n )" + } + }, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "normalized_email": { + "name": "normalized_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'user'" + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + }, + "user_normalized_email_unique": { + "name": "user_normalized_email_unique", + "nullsNotDistinct": false, + "columns": ["normalized_email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_stats": { + "name": "user_stats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "total_manual_executions": { + "name": "total_manual_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_api_calls": { + "name": "total_api_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_webhook_triggers": { + "name": "total_webhook_triggers", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_scheduled_executions": { + "name": "total_scheduled_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_chat_executions": { + "name": "total_chat_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_mcp_executions": { + "name": "total_mcp_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_tokens_used": { + "name": "total_tokens_used", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_cost": { + "name": "total_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_usage_limit": { + "name": "current_usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'5'" + }, + "usage_limit_updated_at": { + "name": "usage_limit_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "current_period_cost": { + "name": "current_period_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "last_period_cost": { + "name": "last_period_cost", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "billed_overage_this_period": { + "name": "billed_overage_this_period", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "pro_period_cost_snapshot": { + "name": "pro_period_cost_snapshot", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "pro_period_cost_snapshot_at": { + "name": "pro_period_cost_snapshot_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "credit_balance": { + "name": "credit_balance", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "total_copilot_cost": { + "name": "total_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_period_copilot_cost": { + "name": "current_period_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "last_period_copilot_cost": { + "name": "last_period_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "total_copilot_tokens": { + "name": "total_copilot_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_copilot_calls": { + "name": "total_copilot_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_mcp_copilot_calls": { + "name": "total_mcp_copilot_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_mcp_copilot_cost": { + "name": "total_mcp_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_period_mcp_copilot_cost": { + "name": "current_period_mcp_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_active": { + "name": "last_active", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "billing_blocked": { + "name": "billing_blocked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "billing_blocked_reason": { + "name": "billing_blocked_reason", + "type": "billing_blocked_reason", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "limit_notifications": { + "name": "limit_notifications", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + } + }, + "indexes": {}, + "foreignKeys": { + "user_stats_user_id_user_id_fk": { + "name": "user_stats_user_id_user_id_fk", + "tableFrom": "user_stats", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_stats_user_id_unique": { + "name": "user_stats_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_table_definitions": { + "name": "user_table_definitions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "schema": { + "name": "schema", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "max_rows": { + "name": "max_rows", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10000 + }, + "row_count": { + "name": "row_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "rows_version": { + "name": "rows_version", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "schema_locked": { + "name": "schema_locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "insert_locked": { + "name": "insert_locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "update_locked": { + "name": "update_locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "delete_locked": { + "name": "delete_locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_table_def_workspace_id_idx": { + "name": "user_table_def_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_folder_id_idx": { + "name": "user_table_def_folder_id_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_workspace_name_unique": { + "name": "user_table_def_workspace_name_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"user_table_definitions\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_archived_at_idx": { + "name": "user_table_def_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_workspace_archived_partial_idx": { + "name": "user_table_def_workspace_archived_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"user_table_definitions\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_table_definitions_workspace_id_workspace_id_fk": { + "name": "user_table_definitions_workspace_id_workspace_id_fk", + "tableFrom": "user_table_definitions", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_table_definitions_folder_id_folder_id_fk": { + "name": "user_table_definitions_folder_id_folder_id_fk", + "tableFrom": "user_table_definitions", + "tableTo": "folder", + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "user_table_definitions_created_by_user_id_fk": { + "name": "user_table_definitions_created_by_user_id_fk", + "tableFrom": "user_table_definitions", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_table_rows": { + "name": "user_table_rows", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "order_key": { + "name": "order_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "user_table_rows_tenant_data_gin_idx": { + "name": "user_table_rows_tenant_data_gin_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"data\" jsonb_path_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "user_table_rows_workspace_table_idx": { + "name": "user_table_rows_workspace_table_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_position_idx": { + "name": "user_table_rows_table_position_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_order_key_idx": { + "name": "user_table_rows_table_order_key_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "order_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_id_id_idx": { + "name": "user_table_rows_table_id_id_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_table_rows_table_id_user_table_definitions_id_fk": { + "name": "user_table_rows_table_id_user_table_definitions_id_fk", + "tableFrom": "user_table_rows", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_table_rows_workspace_id_workspace_id_fk": { + "name": "user_table_rows_workspace_id_workspace_id_fk", + "tableFrom": "user_table_rows", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_table_rows_created_by_user_id_fk": { + "name": "user_table_rows_created_by_user_id_fk", + "tableFrom": "user_table_rows", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "verification_expires_at_idx": { + "name": "verification_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.waitlist": { + "name": "waitlist", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "waitlist_email_unique": { + "name": "waitlist_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhook": { + "name": "webhook", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "registration_status": { + "name": "registration_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "registration_generation": { + "name": "registration_generation", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "config_fingerprint": { + "name": "config_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prepared_at": { + "name": "prepared_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "routing_key": { + "name": "routing_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_config": { + "name": "provider_config", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "failed_count": { + "name": "failed_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_failed_at": { + "name": "last_failed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "path_deployment_unique": { + "name": "path_deployment_unique", + "columns": [ + { + "expression": "path", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"webhook\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_workflow_deployment_idx": { + "name": "webhook_workflow_deployment_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_routing_key_active_idx": { + "name": "webhook_routing_key_active_idx", + "columns": [ + { + "expression": "routing_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"webhook\".\"archived_at\" IS NULL AND \"webhook\".\"routing_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_archived_at_partial_idx": { + "name": "webhook_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"webhook\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_webhook_on_provider_is_active_workflow_id_deploym_bdeed5468": { + "name": "idx_webhook_on_provider_is_active_workflow_id_deploym_bdeed5468", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_tiktok_credential_id_idx": { + "name": "webhook_tiktok_credential_id_idx", + "columns": [ + { + "expression": "((\"provider_config\")::jsonb ->> 'credentialId')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"webhook\".\"provider\" = 'tiktok' AND \"webhook\".\"is_active\" = true AND \"webhook\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_webhook_on_workflow_id_block_id_updated_at_desc": { + "name": "idx_webhook_on_workflow_id_block_id_updated_at_desc", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_active_registration_unique": { + "name": "webhook_active_registration_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"webhook\".\"registration_status\" = 'active' AND \"webhook\".\"block_id\" IS NOT NULL AND \"webhook\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_candidate_registration_unique": { + "name": "webhook_candidate_registration_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"webhook\".\"registration_status\" = 'candidate' AND \"webhook\".\"block_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_registration_status_generation_idx": { + "name": "webhook_registration_status_generation_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "registration_status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "registration_generation", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "webhook_workflow_id_workflow_id_fk": { + "name": "webhook_workflow_id_workflow_id_fk", + "tableFrom": "webhook", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "webhook_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "webhook_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "webhook", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "webhook_registration_status_check": { + "name": "webhook_registration_status_check", + "value": "\"webhook\".\"registration_status\" IS NULL OR \"webhook\".\"registration_status\" IN ('active', 'candidate', 'retired', 'orphaned')" + }, + "webhook_registration_generation_check": { + "name": "webhook_registration_generation_check", + "value": "\"webhook\".\"registration_generation\" IS NULL OR \"webhook\".\"registration_generation\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.webhook_path_claim": { + "name": "webhook_path_claim", + "schema": "", + "columns": { + "path": { + "name": "path", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "webhook_path_claim_workflow_idx": { + "name": "webhook_path_claim_workflow_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "webhook_path_claim_workflow_id_workflow_id_fk": { + "name": "webhook_path_claim_workflow_id_workflow_id_fk", + "tableFrom": "webhook_path_claim", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "webhook_path_claim_generation_check": { + "name": "webhook_path_claim_generation_check", + "value": "\"webhook_path_claim\".\"generation\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.workflow": { + "name": "workflow", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_synced": { + "name": "last_synced", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "is_deployed": { + "name": "is_deployed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deployed_at": { + "name": "deployed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "is_public_api": { + "name": "is_public_api", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "fork_sync_excluded": { + "name": "fork_sync_excluded", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "run_count": { + "name": "run_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workflow_user_id_idx": { + "name": "workflow_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_id_idx": { + "name": "workflow_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_user_workspace_idx": { + "name": "workflow_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_folder_name_active_unique": { + "name": "workflow_workspace_folder_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"folder_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_sort_idx": { + "name": "workflow_folder_sort_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_archived_at_idx": { + "name": "workflow_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_archived_partial_idx": { + "name": "workflow_workspace_archived_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_user_id_user_id_fk": { + "name": "workflow_user_id_user_id_fk", + "tableFrom": "workflow", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_workspace_id_workspace_id_fk": { + "name": "workflow_workspace_id_workspace_id_fk", + "tableFrom": "workflow", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_folder_id_folder_id_fk": { + "name": "workflow_folder_id_folder_id_fk", + "tableFrom": "workflow", + "tableTo": "folder", + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_blocks": { + "name": "workflow_blocks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position_x": { + "name": "position_x", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "position_y": { + "name": "position_y", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "horizontal_handles": { + "name": "horizontal_handles", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "is_wide": { + "name": "is_wide", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "advanced_mode": { + "name": "advanced_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "trigger_mode": { + "name": "trigger_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "height": { + "name": "height", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "sub_blocks": { + "name": "sub_blocks", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "outputs": { + "name": "outputs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_blocks_workflow_id_idx": { + "name": "workflow_blocks_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_blocks_type_idx": { + "name": "workflow_blocks_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_blocks_workflow_id_workflow_id_fk": { + "name": "workflow_blocks_workflow_id_workflow_id_fk", + "tableFrom": "workflow_blocks", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_checkpoints": { + "name": "workflow_checkpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_state": { + "name": "workflow_state", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_checkpoints_user_id_idx": { + "name": "workflow_checkpoints_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_workflow_id_idx": { + "name": "workflow_checkpoints_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_chat_id_idx": { + "name": "workflow_checkpoints_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_message_id_idx": { + "name": "workflow_checkpoints_message_id_idx", + "columns": [ + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_user_workflow_idx": { + "name": "workflow_checkpoints_user_workflow_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_workflow_chat_idx": { + "name": "workflow_checkpoints_workflow_chat_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_created_at_idx": { + "name": "workflow_checkpoints_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_chat_created_at_idx": { + "name": "workflow_checkpoints_chat_created_at_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_checkpoints_user_id_user_id_fk": { + "name": "workflow_checkpoints_user_id_user_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_checkpoints_workflow_id_workflow_id_fk": { + "name": "workflow_checkpoints_workflow_id_workflow_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_checkpoints_chat_id_copilot_chats_id_fk": { + "name": "workflow_checkpoints_chat_id_copilot_chats_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_deployment_operation": { + "name": "workflow_deployment_operation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "previous_active_version_id": { + "name": "previous_active_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "protocol_version": { + "name": "protocol_version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'preparing'" + }, + "component_readiness": { + "name": "component_readiness", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "request_hash": { + "name": "request_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_deployment_operation_workflow_generation_unique": { + "name": "workflow_deployment_operation_workflow_generation_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "generation", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_idempotency_unique": { + "name": "workflow_deployment_operation_workflow_idempotency_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_deployment_operation\".\"idempotency_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_in_flight_unique": { + "name": "workflow_deployment_operation_workflow_in_flight_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_deployment_operation\".\"status\" IN ('preparing', 'activating')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_status_idx": { + "name": "workflow_deployment_operation_workflow_status_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_deployment_version_idx": { + "name": "workflow_deployment_operation_deployment_version_idx", + "columns": [ + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_version_generation_idx": { + "name": "workflow_deployment_operation_workflow_version_generation_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "generation", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_deployment_operation_workflow_id_workflow_id_fk": { + "name": "workflow_deployment_operation_workflow_id_workflow_id_fk", + "tableFrom": "workflow_deployment_operation", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_deployment_operation_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_deployment_operation_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_deployment_operation", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_deployment_operation_previous_active_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_deployment_operation_previous_active_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_deployment_operation", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["previous_active_version_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "workflow_deployment_operation_action_check": { + "name": "workflow_deployment_operation_action_check", + "value": "\"workflow_deployment_operation\".\"action\" IN ('deploy', 'activate')" + }, + "workflow_deployment_operation_status_check": { + "name": "workflow_deployment_operation_status_check", + "value": "\"workflow_deployment_operation\".\"status\" IN ('preparing', 'activating', 'active', 'failed', 'superseded')" + }, + "workflow_deployment_operation_generation_check": { + "name": "workflow_deployment_operation_generation_check", + "value": "\"workflow_deployment_operation\".\"generation\" > 0" + }, + "workflow_deployment_operation_protocol_version_check": { + "name": "workflow_deployment_operation_protocol_version_check", + "value": "\"workflow_deployment_operation\".\"protocol_version\" > 0" + } + }, + "isRLSEnabled": false + }, + "public.workflow_deployment_version": { + "name": "workflow_deployment_version", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workflow_deployment_version_workflow_version_unique": { + "name": "workflow_deployment_version_workflow_version_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_version_workflow_active_idx": { + "name": "workflow_deployment_version_workflow_active_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_version_created_at_idx": { + "name": "workflow_deployment_version_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_deployment_version_workflow_id_workflow_id_fk": { + "name": "workflow_deployment_version_workflow_id_workflow_id_fk", + "tableFrom": "workflow_deployment_version", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_edges": { + "name": "workflow_edges", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_block_id": { + "name": "source_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_block_id": { + "name": "target_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_handle": { + "name": "source_handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_handle": { + "name": "target_handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_edges_workflow_id_idx": { + "name": "workflow_edges_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_edges_workflow_source_idx": { + "name": "workflow_edges_workflow_source_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_edges_workflow_target_idx": { + "name": "workflow_edges_workflow_target_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_edges_workflow_id_workflow_id_fk": { + "name": "workflow_edges_workflow_id_workflow_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_edges_source_block_id_workflow_blocks_id_fk": { + "name": "workflow_edges_source_block_id_workflow_blocks_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow_blocks", + "columnsFrom": ["source_block_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_edges_target_block_id_workflow_blocks_id_fk": { + "name": "workflow_edges_target_block_id_workflow_blocks_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow_blocks", + "columnsFrom": ["target_block_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_execution_logs": { + "name": "workflow_execution_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state_snapshot_id": { + "name": "state_snapshot_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_duration_ms": { + "name": "total_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "execution_data": { + "name": "execution_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "cost": { + "name": "cost", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "cost_total": { + "name": "cost_total", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "models_used": { + "name": "models_used", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "files": { + "name": "files", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_execution_logs_workflow_id_idx": { + "name": "workflow_execution_logs_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_state_snapshot_id_idx": { + "name": "workflow_execution_logs_state_snapshot_id_idx", + "columns": [ + { + "expression": "state_snapshot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_deployment_version_id_idx": { + "name": "workflow_execution_logs_deployment_version_id_idx", + "columns": [ + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_trigger_idx": { + "name": "workflow_execution_logs_trigger_idx", + "columns": [ + { + "expression": "trigger", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_level_idx": { + "name": "workflow_execution_logs_level_idx", + "columns": [ + { + "expression": "level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_started_at_idx": { + "name": "workflow_execution_logs_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_execution_id_unique": { + "name": "workflow_execution_logs_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workflow_started_at_idx": { + "name": "workflow_execution_logs_workflow_started_at_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workspace_started_at_idx": { + "name": "workflow_execution_logs_workspace_started_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workspace_started_at_id_desc_idx": { + "name": "workflow_execution_logs_workspace_started_at_id_desc_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"started_at\" DESC NULLS LAST", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "\"id\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workspace_cost_total_idx": { + "name": "workflow_execution_logs_workspace_cost_total_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_total", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_models_used_idx": { + "name": "workflow_execution_logs_models_used_idx", + "columns": [ + { + "expression": "models_used", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "workflow_execution_logs_workspace_ended_at_id_idx": { + "name": "workflow_execution_logs_workspace_ended_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"ended_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_running_started_at_idx": { + "name": "workflow_execution_logs_running_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "status = 'running'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_completed_ended_at_idx": { + "name": "workflow_execution_logs_completed_ended_at_idx", + "columns": [ + { + "expression": "ended_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_execution_logs\".\"status\" = 'completed' AND \"workflow_execution_logs\".\"level\" = 'info' AND \"workflow_execution_logs\".\"ended_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_execution_logs_workflow_id_workflow_id_fk": { + "name": "workflow_execution_logs_workflow_id_workflow_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workflow_execution_logs_workspace_id_workspace_id_fk": { + "name": "workflow_execution_logs_workspace_id_workspace_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_execution_logs_state_snapshot_id_workflow_execution_snapshots_id_fk": { + "name": "workflow_execution_logs_state_snapshot_id_workflow_execution_snapshots_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow_execution_snapshots", + "columnsFrom": ["state_snapshot_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "workflow_execution_logs_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_execution_logs_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_execution_snapshots": { + "name": "workflow_execution_snapshots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state_hash": { + "name": "state_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state_data": { + "name": "state_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_snapshots_workflow_id_idx": { + "name": "workflow_snapshots_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_hash_idx": { + "name": "workflow_snapshots_hash_idx", + "columns": [ + { + "expression": "state_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_workflow_hash_idx": { + "name": "workflow_snapshots_workflow_hash_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "state_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_created_at_idx": { + "name": "workflow_snapshots_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_execution_snapshots_workflow_id_workflow_id_fk": { + "name": "workflow_execution_snapshots_workflow_id_workflow_id_fk", + "tableFrom": "workflow_execution_snapshots", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_mcp_server": { + "name": "workflow_mcp_server", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_mcp_server_workspace_id_idx": { + "name": "workflow_mcp_server_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_server_created_by_idx": { + "name": "workflow_mcp_server_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_server_deleted_at_idx": { + "name": "workflow_mcp_server_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_server_workspace_deleted_partial_idx": { + "name": "workflow_mcp_server_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_mcp_server\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_mcp_server_workspace_id_workspace_id_fk": { + "name": "workflow_mcp_server_workspace_id_workspace_id_fk", + "tableFrom": "workflow_mcp_server", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_mcp_server_created_by_user_id_fk": { + "name": "workflow_mcp_server_created_by_user_id_fk", + "tableFrom": "workflow_mcp_server", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_mcp_tool": { + "name": "workflow_mcp_tool", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_description": { + "name": "tool_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parameter_schema": { + "name": "parameter_schema", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "parameter_description_overrides": { + "name": "parameter_description_overrides", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'::json" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_mcp_tool_server_id_idx": { + "name": "workflow_mcp_tool_server_id_idx", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_tool_workflow_id_idx": { + "name": "workflow_mcp_tool_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_tool_server_workflow_unique": { + "name": "workflow_mcp_tool_server_workflow_unique", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_mcp_tool\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_tool_archived_at_partial_idx": { + "name": "workflow_mcp_tool_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_mcp_tool\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_mcp_tool_server_id_workflow_mcp_server_id_fk": { + "name": "workflow_mcp_tool_server_id_workflow_mcp_server_id_fk", + "tableFrom": "workflow_mcp_tool", + "tableTo": "workflow_mcp_server", + "columnsFrom": ["server_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_mcp_tool_workflow_id_workflow_id_fk": { + "name": "workflow_mcp_tool_workflow_id_workflow_id_fk", + "tableFrom": "workflow_mcp_tool", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_schedule": { + "name": "workflow_schedule", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deployment_operation_id": { + "name": "deployment_operation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cron_expression": { + "name": "cron_expression", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_run_at": { + "name": "next_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_ran_at": { + "name": "last_ran_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_queued_at": { + "name": "last_queued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trigger_type": { + "name": "trigger_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'UTC'" + }, + "failed_count": { + "name": "failed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "infra_retry_count": { + "name": "infra_retry_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "last_failed_at": { + "name": "last_failed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "source_type": { + "name": "source_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'workflow'" + }, + "job_title": { + "name": "job_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lifecycle": { + "name": "lifecycle", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'persistent'" + }, + "success_condition": { + "name": "success_condition", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "max_runs": { + "name": "max_runs", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "run_count": { + "name": "run_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "source_chat_id": { + "name": "source_chat_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_task_name": { + "name": "source_task_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_user_id": { + "name": "source_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_workspace_id": { + "name": "source_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "job_history": { + "name": "job_history", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "contexts": { + "name": "contexts", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "excluded_dates": { + "name": "excluded_dates", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "ends_at": { + "name": "ends_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_schedule_workflow_block_deployment_unique": { + "name": "workflow_schedule_workflow_block_deployment_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_schedule\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_workflow_deployment_idx": { + "name": "workflow_schedule_workflow_deployment_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_archived_at_partial_idx": { + "name": "workflow_schedule_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_schedule\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workflow_schedule_on_source_workspace_id_source_t_c07f3bba6": { + "name": "idx_workflow_schedule_on_source_workspace_id_source_t_c07f3bba6", + "columns": [ + { + "expression": "source_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_due_workflow_idx": { + "name": "workflow_schedule_due_workflow_idx", + "columns": [ + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_schedule\".\"archived_at\" IS NULL AND \"workflow_schedule\".\"status\" NOT IN ('disabled', 'completed') AND (\"workflow_schedule\".\"source_type\" = 'workflow' OR \"workflow_schedule\".\"source_type\" IS NULL)", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_due_job_idx": { + "name": "workflow_schedule_due_job_idx", + "columns": [ + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_schedule\".\"archived_at\" IS NULL AND \"workflow_schedule\".\"status\" NOT IN ('disabled', 'completed') AND \"workflow_schedule\".\"source_type\" = 'job'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_schedule_workflow_id_workflow_id_fk": { + "name": "workflow_schedule_workflow_id_workflow_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_schedule_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_deployment_operation_id_workflow_deployment_operation_id_fk": { + "name": "workflow_schedule_deployment_operation_id_workflow_deployment_operation_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow_deployment_operation", + "columnsFrom": ["deployment_operation_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workflow_schedule_source_user_id_user_id_fk": { + "name": "workflow_schedule_source_user_id_user_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "user", + "columnsFrom": ["source_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_source_workspace_id_workspace_id_fk": { + "name": "workflow_schedule_source_workspace_id_workspace_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workspace", + "columnsFrom": ["source_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_subflows": { + "name": "workflow_subflows", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_subflows_workflow_id_idx": { + "name": "workflow_subflows_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_subflows_workflow_type_idx": { + "name": "workflow_subflows_workflow_type_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_subflows_workflow_id_workflow_id_fk": { + "name": "workflow_subflows_workflow_id_workflow_id_fk", + "tableFrom": "workflow_subflows", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace": { + "name": "workspace", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'#33C482'" + }, + "logo_url": { + "name": "logo_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_mode": { + "name": "workspace_mode", + "type": "workspace_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'grandfathered_shared'" + }, + "billed_account_user_id": { + "name": "billed_account_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "allow_personal_api_keys": { + "name": "allow_personal_api_keys", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "inbox_enabled": { + "name": "inbox_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "inbox_address": { + "name": "inbox_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "inbox_provider_id": { + "name": "inbox_provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "organization_assigned_at": { + "name": "organization_assigned_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "forked_from_workspace_id": { + "name": "forked_from_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_owner_id_idx": { + "name": "workspace_owner_id_idx", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_organization_id_idx": { + "name": "workspace_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_mode_idx": { + "name": "workspace_mode_idx", + "columns": [ + { + "expression": "workspace_mode", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_forked_from_workspace_id_idx": { + "name": "workspace_forked_from_workspace_id_idx", + "columns": [ + { + "expression": "forked_from_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_owner_id_user_id_fk": { + "name": "workspace_owner_id_user_id_fk", + "tableFrom": "workspace", + "tableTo": "user", + "columnsFrom": ["owner_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_organization_id_organization_id_fk": { + "name": "workspace_organization_id_organization_id_fk", + "tableFrom": "workspace", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspace_billed_account_user_id_user_id_fk": { + "name": "workspace_billed_account_user_id_user_id_fk", + "tableFrom": "workspace", + "tableTo": "user", + "columnsFrom": ["billed_account_user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "workspace_forked_from_workspace_id_workspace_id_fk": { + "name": "workspace_forked_from_workspace_id_workspace_id_fk", + "tableFrom": "workspace", + "tableTo": "workspace", + "columnsFrom": ["forked_from_workspace_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "workspace_storage_used_bytes_non_negative": { + "name": "workspace_storage_used_bytes_non_negative", + "value": "\"workspace\".\"storage_used_bytes\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.workspace_byok_keys": { + "name": "workspace_byok_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_api_key": { + "name": "encrypted_api_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_byok_workspace_provider_idx": { + "name": "workspace_byok_workspace_provider_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_byok_keys_workspace_id_workspace_id_fk": { + "name": "workspace_byok_keys_workspace_id_workspace_id_fk", + "tableFrom": "workspace_byok_keys", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_byok_keys_created_by_user_id_fk": { + "name": "workspace_byok_keys_created_by_user_id_fk", + "tableFrom": "workspace_byok_keys", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_environment": { + "name": "workspace_environment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_environment_workspace_unique": { + "name": "workspace_environment_workspace_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_environment_workspace_id_workspace_id_fk": { + "name": "workspace_environment_workspace_id_workspace_id_fk", + "tableFrom": "workspace_environment", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file": { + "name": "workspace_file", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_file_workspace_id_idx": { + "name": "workspace_file_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_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_files": { + "name": "workspace_files", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "context": { + "name": "context", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "original_name": { + "name": "original_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "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_folder_id_fk": { + "name": "workspace_files_folder_id_folder_id_fk", + "tableFrom": "workspace_files", + "tableTo": "folder", + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspace_files_chat_id_copilot_chats_id_fk": { + "name": "workspace_files_chat_id_copilot_chats_id_fk", + "tableFrom": "workspace_files", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_block_map": { + "name": "workspace_fork_block_map", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_workflow_id": { + "name": "parent_workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_block_id": { + "name": "parent_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_workflow_id": { + "name": "child_workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_block_id": { + "name": "child_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_block_map_child_ws_parent_unique": { + "name": "workspace_fork_block_map_child_ws_parent_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_block_map_child_ws_child_unique": { + "name": "workspace_fork_block_map_child_ws_child_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "child_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_block_map_child_ws_parent_wf_idx": { + "name": "workspace_fork_block_map_child_ws_parent_wf_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_block_map_child_ws_child_wf_idx": { + "name": "workspace_fork_block_map_child_ws_child_wf_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "child_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_block_map_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_block_map_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_block_map", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_dependent_value": { + "name": "workspace_fork_dependent_value", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_workflow_id": { + "name": "target_workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_block_id": { + "name": "target_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sub_block_key": { + "name": "sub_block_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_dependent_value_child_ws_wf_idx": { + "name": "workspace_fork_dependent_value_child_ws_wf_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_dependent_value_field_unique": { + "name": "workspace_fork_dependent_value_field_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sub_block_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_dependent_value_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_dependent_value_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_dependent_value", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_promote_run": { + "name": "workspace_fork_promote_run", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_workspace_id": { + "name": "source_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_workspace_id": { + "name": "target_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "direction": { + "name": "direction", + "type": "workspace_fork_promote_direction", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "snapshot": { + "name": "snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_promote_run_child_ws_target_unique": { + "name": "workspace_fork_promote_run_child_ws_target_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_promote_run_target_ws_idx": { + "name": "workspace_fork_promote_run_target_ws_idx", + "columns": [ + { + "expression": "target_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_promote_run_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_promote_run_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_promote_run", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_fork_promote_run_created_by_user_id_fk": { + "name": "workspace_fork_promote_run_created_by_user_id_fk", + "tableFrom": "workspace_fork_promote_run", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_resource_map": { + "name": "workspace_fork_resource_map", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "workspace_fork_resource_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "parent_resource_id": { + "name": "parent_resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_resource_id": { + "name": "child_resource_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_resource_map_child_ws_idx": { + "name": "workspace_fork_resource_map_child_ws_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_resource_map_child_ws_type_idx": { + "name": "workspace_fork_resource_map_child_ws_type_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_resource_map_child_type_parent_unique": { + "name": "workspace_fork_resource_map_child_type_parent_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_resource_map_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_resource_map_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_resource_map", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_fork_resource_map_created_by_user_id_fk": { + "name": "workspace_fork_resource_map_created_by_user_id_fk", + "tableFrom": "workspace_fork_resource_map", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.academy_cert_status": { + "name": "academy_cert_status", + "schema": "public", + "values": ["active", "revoked", "expired"] + }, + "public.background_work_kind": { + "name": "background_work_kind", + "schema": "public", + "values": ["deployment_side_effects", "fork_content_copy", "fork_sync", "fork_rollback"] + }, + "public.background_work_status_value": { + "name": "background_work_status_value", + "schema": "public", + "values": ["pending", "processing", "completed", "completed_with_warnings", "failed"] + }, + "public.billing_blocked_reason": { + "name": "billing_blocked_reason", + "schema": "public", + "values": ["payment_failed", "dispute"] + }, + "public.billing_entity_type": { + "name": "billing_entity_type", + "schema": "public", + "values": ["user", "organization"] + }, + "public.chat_type": { + "name": "chat_type", + "schema": "public", + "values": ["mothership", "copilot"] + }, + "public.copilot_async_tool_status": { + "name": "copilot_async_tool_status", + "schema": "public", + "values": ["pending", "running", "completed", "failed", "cancelled", "delivered"] + }, + "public.copilot_run_status": { + "name": "copilot_run_status", + "schema": "public", + "values": ["active", "paused_waiting_for_tool", "resuming", "complete", "error", "cancelled"] + }, + "public.copilot_tool_permission_decision": { + "name": "copilot_tool_permission_decision", + "schema": "public", + "values": ["allow", "allow_chat", "always_allow", "skip"] + }, + "public.credential_member_role": { + "name": "credential_member_role", + "schema": "public", + "values": ["admin", "member"] + }, + "public.credential_member_status": { + "name": "credential_member_status", + "schema": "public", + "values": ["active", "pending", "revoked"] + }, + "public.credential_type": { + "name": "credential_type", + "schema": "public", + "values": ["oauth", "env_workspace", "env_personal", "service_account"] + }, + "public.data_drain_cadence": { + "name": "data_drain_cadence", + "schema": "public", + "values": ["hourly", "daily"] + }, + "public.data_drain_destination": { + "name": "data_drain_destination", + "schema": "public", + "values": ["s3", "gcs", "azure_blob", "datadog", "bigquery", "snowflake", "webhook"] + }, + "public.data_drain_run_status": { + "name": "data_drain_run_status", + "schema": "public", + "values": ["running", "success", "failed"] + }, + "public.data_drain_run_trigger": { + "name": "data_drain_run_trigger", + "schema": "public", + "values": ["cron", "manual"] + }, + "public.data_drain_source": { + "name": "data_drain_source", + "schema": "public", + "values": ["workflow_logs", "job_logs", "audit_logs", "copilot_chats", "copilot_runs"] + }, + "public.execution_large_value_reference_source": { + "name": "execution_large_value_reference_source", + "schema": "public", + "values": ["execution_log", "paused_snapshot"] + }, + "public.folder_resource_type": { + "name": "folder_resource_type", + "schema": "public", + "values": ["workflow", "file", "knowledge_base", "table"] + }, + "public.invitation_kind": { + "name": "invitation_kind", + "schema": "public", + "values": ["organization", "workspace"] + }, + "public.invitation_membership_intent": { + "name": "invitation_membership_intent", + "schema": "public", + "values": ["internal", "external"] + }, + "public.invitation_status": { + "name": "invitation_status", + "schema": "public", + "values": ["pending", "accepted", "rejected", "cancelled", "expired"] + }, + "public.permission_type": { + "name": "permission_type", + "schema": "public", + "values": ["admin", "write", "read"] + }, + "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_fork_promote_direction": { + "name": "workspace_fork_promote_direction", + "schema": "public", + "values": ["push", "pull"] + }, + "public.workspace_fork_resource_type": { + "name": "workspace_fork_resource_type", + "schema": "public", + "values": [ + "workflow", + "oauth_credential", + "service_account_credential", + "env_var", + "table", + "knowledge_base", + "knowledge_document", + "file", + "mcp_server", + "workflow_mcp_server", + "custom_tool", + "skill" + ] + }, + "public.workspace_mode": { + "name": "workspace_mode", + "schema": "public", + "values": ["personal", "organization", "grandfathered_shared"] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/db/migrations/meta/_journal.json b/packages/db/migrations/meta/_journal.json index 43d813aafee..e4fe6100c77 100644 --- a/packages/db/migrations/meta/_journal.json +++ b/packages/db/migrations/meta/_journal.json @@ -1933,6 +1933,13 @@ "when": 1785352177983, "tag": "0276_drop_legacy_folder_tables", "breakpoints": true + }, + { + "idx": 277, + "version": "7", + "when": 1785366248935, + "tag": "0277_condemned_sandman", + "breakpoints": true } ] } diff --git a/packages/db/schema.ts b/packages/db/schema.ts index 778f1aa8420..6a2a74fe472 100644 --- a/packages/db/schema.ts +++ b/packages/db/schema.ts @@ -3672,6 +3672,76 @@ export const asyncJobs = pgTable( }) ) +export const newsletterAudienceRuns = pgTable( + 'newsletter_audience_runs', + { + id: text('id').primaryKey(), + createdById: text('created_by_id').references(() => user.id, { onDelete: 'set null' }), + name: text('name').notNull(), + prompt: text('prompt').notNull(), + criteria: jsonb('criteria').notNull(), + status: text('status').notNull().default('draft'), + totalMatched: integer('total_matched').notNull().default(0), + excludedBanned: integer('excluded_banned').notNull().default(0), + excludedUnverified: integer('excluded_unverified').notNull().default(0), + excludedUnsubscribed: integer('excluded_unsubscribed').notNull().default(0), + excludedSuppressed: integer('excluded_suppressed').notNull().default(0), + finalRecipientCount: integer('final_recipient_count').notNull().default(0), + sampleRecipients: jsonb('sample_recipients').notNull().default('[]'), + resendSegmentId: text('resend_segment_id'), + resendSegmentName: text('resend_segment_name'), + resendSyncedAt: timestamp('resend_synced_at'), + snapshotVersion: integer('snapshot_version').notNull().default(0), + resendSyncAttempt: integer('resend_sync_attempt').notNull().default(0), + resendSyncJobId: text('resend_sync_job_id'), + error: text('error'), + finalizedAt: timestamp('finalized_at'), + createdAt: timestamp('created_at').notNull().defaultNow(), + updatedAt: timestamp('updated_at').notNull().defaultNow(), + }, + (table) => ({ + createdAtIdx: index('newsletter_audience_runs_created_at_idx').on(table.createdAt), + createdByIdx: index('newsletter_audience_runs_created_by_idx').on(table.createdById), + statusIdx: index('newsletter_audience_runs_status_idx').on(table.status), + resendSyncJobIdx: index('newsletter_audience_runs_resend_sync_job_idx').on( + table.resendSyncJobId + ), + }) +) + +export const newsletterAudienceRecipients = pgTable( + 'newsletter_audience_recipients', + { + id: text('id').primaryKey(), + runId: text('run_id') + .notNull() + .references(() => newsletterAudienceRuns.id, { onDelete: 'cascade' }), + userId: text('user_id').references(() => user.id, { onDelete: 'set null' }), + email: text('email').notNull(), + name: text('name'), + inclusionReason: jsonb('inclusion_reason').notNull().default('{}'), + snapshotVersion: integer('snapshot_version').notNull(), + resendContactId: text('resend_contact_id'), + resendStatus: text('resend_status').notNull().default('pending'), + error: text('error'), + createdAt: timestamp('created_at').notNull().defaultNow(), + updatedAt: timestamp('updated_at').notNull().defaultNow(), + }, + (table) => ({ + runIdIdx: index('newsletter_audience_recipients_run_id_idx').on(table.runId), + userIdIdx: index('newsletter_audience_recipients_user_id_idx').on(table.userId), + emailIdx: index('newsletter_audience_recipients_email_idx').on(table.email), + runVersionEmailUnique: uniqueIndex( + 'newsletter_audience_recipients_run_version_email_unique' + ).on(table.runId, table.snapshotVersion, table.email), + resendStatusEmailIdx: index('newsletter_audience_recipients_resend_status_email_idx').on( + table.runId, + table.resendStatus, + table.email + ), + }) +) + /** * Knowledge Connector - persistent link to an external source (Confluence, Google Drive, etc.) * that syncs documents into a knowledge base. diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts index 8df1ad2a511..73df4fc84d8 100644 --- a/scripts/check-api-validation-contracts.ts +++ b/scripts/check-api-validation-contracts.ts @@ -9,8 +9,8 @@ const QUERY_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/queries') const SELECTOR_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/selectors') const BASELINE = { - totalRoutes: 997, - zodRoutes: 997, + totalRoutes: 1004, + zodRoutes: 1004, nonZodRoutes: 0, } as const From 46eb81eeefaec312a0cb2f28158606846cbcc1a0 Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Wed, 29 Jul 2026 17:03:01 -0700 Subject: [PATCH 2/5] fix(newsletters): handle review recovery cases --- .../settings/account-settings-renderer.tsx | 3 +- apps/sim/lib/newsletters/push-resend.test.ts | 175 +++++++++++++-- apps/sim/lib/newsletters/push-resend.ts | 57 +++-- apps/sim/lib/newsletters/resend.test.ts | 36 +++- apps/sim/lib/newsletters/resend.ts | 45 ++-- apps/sim/lib/newsletters/runs.test.ts | 80 +++++++ apps/sim/lib/newsletters/runs.ts | 200 +++++++++++++----- packages/testing/src/mocks/schema.mock.ts | 39 ++++ 8 files changed, 535 insertions(+), 100 deletions(-) create mode 100644 apps/sim/lib/newsletters/runs.test.ts diff --git a/apps/sim/components/settings/account-settings-renderer.tsx b/apps/sim/components/settings/account-settings-renderer.tsx index 9061bb5f8f0..6c518c5d443 100644 --- a/apps/sim/components/settings/account-settings-renderer.tsx +++ b/apps/sim/components/settings/account-settings-renderer.tsx @@ -49,5 +49,6 @@ export function AccountSettingsRenderer({ section }: AccountSettingsRendererProp if (section === 'api-keys') return if (section === 'admin') return if (section === 'mothership') return - return + if (section === 'newsletters') return + return null } diff --git a/apps/sim/lib/newsletters/push-resend.test.ts b/apps/sim/lib/newsletters/push-resend.test.ts index 6d7f5361dad..db6c6f5d782 100644 --- a/apps/sim/lib/newsletters/push-resend.test.ts +++ b/apps/sim/lib/newsletters/push-resend.test.ts @@ -15,6 +15,7 @@ const mocks = vi.hoisted(() => ({ isAsyncJobEnqueueError: vi.fn(), markFailed: vi.fn(), markPushed: vi.fn(), + queueCancel: vi.fn(), queueEnqueue: vi.fn(), queueGetJob: vi.fn(), requireAttempt: vi.fn(), @@ -31,6 +32,8 @@ vi.mock('@/lib/core/async-jobs', () => ({ JOB_STATUS: { COMPLETED: 'completed', FAILED: 'failed', + PENDING: 'pending', + PROCESSING: 'processing', }, })) @@ -70,6 +73,7 @@ describe('newsletter Resend queueing', () => { vi.clearAllMocks() mocks.getAsyncBackendType.mockReturnValue('trigger-dev') mocks.getJobQueue.mockResolvedValue({ + cancelJob: mocks.queueCancel, enqueue: mocks.queueEnqueue, getJob: mocks.queueGetJob, }) @@ -93,6 +97,7 @@ describe('newsletter Resend queueing', () => { expect.objectContaining({ jobId: 'newsletter_resend_run-1_2', maxAttempts: 3 }) ) expect(mocks.setJob).toHaveBeenCalledWith('run-1', 2, 'trigger-run-123') + expect(mocks.resetFailedRecipients).not.toHaveBeenCalled() expect(result.jobId).toBe('trigger-run-123') }) @@ -142,23 +147,151 @@ describe('newsletter Resend queueing', () => { ) }) - it('moves a newsletter run to failed when its persisted database job failed', async () => { - mocks.getAsyncBackendType.mockReturnValue('database') - mocks.claimAttempt.mockResolvedValue({ - attempt: 2, - jobId: 'newsletter_resend_run-1_2', - run, - shouldEnqueue: false, - }) + it('starts a new attempt when a persisted Trigger.dev job failed', async () => { + mocks.claimAttempt + .mockResolvedValueOnce({ + attempt: 2, + jobId: 'trigger-run-failed', + run, + shouldEnqueue: false, + }) + .mockResolvedValueOnce({ + attempt: 3, + jobId: null, + run: { ...run, resendSyncJobId: null }, + shouldEnqueue: true, + }) mocks.queueGetJob.mockResolvedValue({ - id: 'newsletter_resend_run-1_2', + id: 'trigger-run-failed', status: 'failed', error: 'worker stopped', }) + mocks.queueEnqueue.mockResolvedValue('trigger-run-retry') + mocks.setJob.mockResolvedValue({ ...run, resendSyncJobId: 'trigger-run-retry' }) + + const result = await enqueueNewsletterResendSync('run-1', 'admin-1') - await expect(enqueueNewsletterResendSync('run-1', 'admin-1')).rejects.toThrow('worker stopped') expect(mocks.markFailed).toHaveBeenCalledWith('run-1', 2, expect.any(Error)) - expect(mocks.queueEnqueue).not.toHaveBeenCalled() + expect(mocks.queueEnqueue).toHaveBeenCalledWith( + 'newsletter-resend-sync', + { runId: 'run-1', attempt: 3, requestedById: 'admin-1' }, + expect.objectContaining({ jobId: 'newsletter_resend_run-1_3' }) + ) + expect(result.jobId).toBe('trigger-run-retry') + }) + + it('starts a new attempt when a completed job did not finalize the newsletter run', async () => { + mocks.claimAttempt + .mockResolvedValueOnce({ + attempt: 2, + jobId: 'trigger-run-completed', + run, + shouldEnqueue: false, + }) + .mockResolvedValueOnce({ + attempt: 3, + jobId: null, + run: { ...run, resendSyncJobId: null }, + shouldEnqueue: true, + }) + mocks.queueGetJob.mockResolvedValue({ + id: 'trigger-run-completed', + status: 'completed', + }) + mocks.queueEnqueue.mockResolvedValue('trigger-run-retry') + mocks.setJob.mockResolvedValue({ ...run, resendSyncJobId: 'trigger-run-retry' }) + + const result = await enqueueNewsletterResendSync('run-1', 'admin-1') + + expect(mocks.markFailed).toHaveBeenCalledWith('run-1', 2, expect.any(Error)) + expect(mocks.queueEnqueue).toHaveBeenCalledWith( + 'newsletter-resend-sync', + { runId: 'run-1', attempt: 3, requestedById: 'admin-1' }, + expect.objectContaining({ jobId: 'newsletter_resend_run-1_3' }) + ) + expect(result.jobId).toBe('trigger-run-retry') + }) + + it.each([ + ['completed', { id: 'trigger-run-existing', status: 'completed' }], + ['processing', { id: 'trigger-run-existing', status: 'processing' }], + ['missing', null], + ])( + 'keeps the stored job when the newsletter run is pushed and the provider job is %s', + async (_providerState, providerJob) => { + const pushedRun = { ...run, status: 'pushed' } + mocks.claimAttempt.mockResolvedValue({ + attempt: 2, + jobId: 'trigger-run-existing', + run: pushedRun, + shouldEnqueue: false, + }) + mocks.queueGetJob.mockResolvedValue(providerJob) + + const result = await enqueueNewsletterResendSync('run-1', 'admin-1') + + expect(mocks.queueGetJob).not.toHaveBeenCalled() + expect(mocks.markFailed).not.toHaveBeenCalled() + expect(mocks.queueEnqueue).not.toHaveBeenCalled() + expect(result).toEqual({ run: pushedRun, jobId: 'trigger-run-existing' }) + } + ) + + it('re-enqueues when a stored Trigger.dev run no longer exists', async () => { + mocks.claimAttempt + .mockResolvedValueOnce({ + attempt: 2, + jobId: 'trigger-run-missing', + run, + shouldEnqueue: false, + }) + .mockResolvedValueOnce({ + attempt: 3, + jobId: null, + run, + shouldEnqueue: true, + }) + mocks.queueGetJob.mockResolvedValue(null) + + await enqueueNewsletterResendSync('run-1', 'admin-1') + + expect(mocks.markFailed).toHaveBeenCalledWith('run-1', 2, expect.any(Error)) + expect(mocks.queueEnqueue).toHaveBeenCalledWith( + 'newsletter-resend-sync', + { runId: 'run-1', attempt: 3, requestedById: 'admin-1' }, + expect.objectContaining({ jobId: 'newsletter_resend_run-1_3' }) + ) + }) + + it('cancels and replaces an active Trigger.dev run when an admin resumes it', async () => { + mocks.claimAttempt + .mockResolvedValueOnce({ + attempt: 2, + jobId: 'trigger-run-active', + run, + shouldEnqueue: false, + }) + .mockResolvedValueOnce({ + attempt: 3, + jobId: null, + run, + shouldEnqueue: true, + }) + mocks.queueGetJob.mockResolvedValue({ + id: 'trigger-run-active', + status: 'processing', + }) + + const result = await enqueueNewsletterResendSync('run-1', 'admin-1') + + expect(mocks.queueCancel).toHaveBeenCalledWith('trigger-run-active') + expect(mocks.markFailed).toHaveBeenCalledWith('run-1', 2, expect.any(Error)) + expect(mocks.queueEnqueue).toHaveBeenCalledWith( + 'newsletter-resend-sync', + { runId: 'run-1', attempt: 3, requestedById: 'admin-1' }, + expect.objectContaining({ jobId: 'newsletter_resend_run-1_3' }) + ) + expect(result.jobId).toBe('trigger-run-123') }) it('resets failed recipients before a task retry', async () => { @@ -173,10 +306,28 @@ describe('newsletter Resend queueing', () => { requestedById: 'admin-1', }) - expect(mocks.resetFailedRecipients).toHaveBeenCalledWith('run-1') + expect(mocks.resetFailedRecipients).toHaveBeenCalledWith('run-1', 2) expect(mocks.markPushed).toHaveBeenCalledWith('run-1', 2, 'segment-1', 'Segment 1') }) + it('does not mark the run pushed while recipients remain pending', async () => { + mocks.requireAttempt.mockResolvedValue(run) + mocks.getExcludedEmails.mockResolvedValue(new Set()) + mocks.getPendingRecipients.mockResolvedValue([]) + mocks.countByStatus.mockResolvedValue({ pending: 1 }) + + await expect( + runNewsletterResendSync({ + runId: 'run-1', + attempt: 2, + requestedById: 'admin-1', + }) + ).rejects.toThrow('1 remain pending') + + expect(mocks.markPushed).not.toHaveBeenCalled() + expect(mocks.markFailed).toHaveBeenCalledWith('run-1', 2, expect.any(Error)) + }) + it('treats same-attempt worker re-entry after success as a no-op', async () => { mocks.requireAttempt.mockResolvedValue({ ...run, status: 'pushed' }) diff --git a/apps/sim/lib/newsletters/push-resend.ts b/apps/sim/lib/newsletters/push-resend.ts index c04070cebc4..314693a0a20 100644 --- a/apps/sim/lib/newsletters/push-resend.ts +++ b/apps/sim/lib/newsletters/push-resend.ts @@ -67,11 +67,13 @@ export async function runNewsletterResendSync( try { signal?.throwIfAborted() const run = await requireNewsletterRunAttempt(runId, attempt) + signal?.throwIfAborted() if (run.status === 'pushed') return if (run.status !== 'finalized' && run.status !== 'pushing' && run.status !== 'failed') { throw new Error('Newsletter run must be finalized before pushing to Resend') } - await resetFailedNewsletterRecipients(runId) + await resetFailedNewsletterRecipients(runId, attempt) + await requireNewsletterRunAttempt(runId, attempt) signal?.throwIfAborted() const segment = @@ -92,7 +94,11 @@ export async function runNewsletterResendSync( while (true) { signal?.throwIfAborted() - const recipients = await getPendingNewsletterRecipients(runId, NEWSLETTER_RESEND_BATCH_SIZE) + const recipients = await getPendingNewsletterRecipients( + runId, + attempt, + NEWSLETTER_RESEND_BATCH_SIZE + ) if (recipients.length === 0) break await runWithConcurrency( @@ -107,6 +113,7 @@ export async function runNewsletterResendSync( ) { await updateRecipientSyncStatus( runId, + attempt, recipient.snapshotVersion, recipient.email, 'excluded' @@ -125,6 +132,7 @@ export async function runNewsletterResendSync( signal?.throwIfAborted() await updateRecipientSyncStatus( runId, + attempt, recipient.snapshotVersion, recipient.email, result.status, @@ -136,6 +144,7 @@ export async function runNewsletterResendSync( signal?.throwIfAborted() await updateRecipientSyncStatus( runId, + attempt, recipient.snapshotVersion, recipient.email, 'failed', @@ -157,11 +166,14 @@ export async function runNewsletterResendSync( } signal?.throwIfAborted() - const statusCounts = await countNewsletterRecipientsByStatus(runId) + const statusCounts = await countNewsletterRecipientsByStatus(runId, attempt) signal?.throwIfAborted() const failed = statusCounts.failed ?? 0 - if (failed > 0) { - throw new Error(`${failed} recipients failed to sync to Resend`) + const pending = statusCounts.pending ?? 0 + if (failed > 0 || pending > 0) { + throw new Error( + `Newsletter sync incomplete: ${failed} recipients failed and ${pending} remain pending` + ) } signal?.throwIfAborted() @@ -174,28 +186,43 @@ export async function runNewsletterResendSync( } export async function enqueueNewsletterResendSync(runId: string, requestedById: string) { - const claim = await claimNewsletterRunResendAttempt(runId) + let claim = await claimNewsletterRunResendAttempt(runId) const queue = await getJobQueue() + const backendType = getAsyncBackendType() if (!claim.shouldEnqueue && claim.jobId) { - if (getAsyncBackendType() !== 'database') { + if (claim.run.status === 'pushed') { return { run: claim.run, jobId: claim.jobId } } - const persistedJob = await queue.getJob(claim.jobId) if (persistedJob?.status === JOB_STATUS.COMPLETED) { - return { run: claim.run, jobId: claim.jobId } - } - if (persistedJob?.status === JOB_STATUS.FAILED) { - const error = new Error(persistedJob.error ?? 'Newsletter sync database job failed') + const error = new Error('Newsletter sync job completed without finalizing the newsletter run') + await markNewsletterRunPushFailed(runId, claim.attempt, error) + claim = await claimNewsletterRunResendAttempt(runId) + } else if (backendType !== 'database') { + if ( + persistedJob?.status === JOB_STATUS.PENDING || + persistedJob?.status === JOB_STATUS.PROCESSING + ) { + await queue.cancelJob(claim.jobId) + } + const error = new Error( + persistedJob?.error ?? 'Newsletter sync was resumed with a fresh background job' + ) + await markNewsletterRunPushFailed(runId, claim.attempt, error) + claim = await claimNewsletterRunResendAttempt(runId) + } else if (persistedJob?.status === JOB_STATUS.FAILED) { + const error = new Error(persistedJob.error ?? 'Newsletter sync job failed') await markNewsletterRunPushFailed(runId, claim.attempt, error) - throw error + claim = await claimNewsletterRunResendAttempt(runId) } } - const enqueueKey = claim.jobId ?? `newsletter_resend_${runId}_${claim.attempt}` + const enqueueKey = + backendType === 'database' && claim.jobId + ? claim.jobId + : `newsletter_resend_${runId}_${claim.attempt}` let jobId: string try { - await resetFailedNewsletterRecipients(runId) jobId = await queue.enqueue( 'newsletter-resend-sync', { runId, attempt: claim.attempt, requestedById }, diff --git a/apps/sim/lib/newsletters/resend.test.ts b/apps/sim/lib/newsletters/resend.test.ts index c0379b4fd62..65b257d2725 100644 --- a/apps/sim/lib/newsletters/resend.test.ts +++ b/apps/sim/lib/newsletters/resend.test.ts @@ -127,7 +127,10 @@ describe('newsletter Resend service', () => { it('normalizes suppressed email addresses', async () => { fetchMock.mockResolvedValueOnce( jsonResponse({ - data: [{ email: ' First@Example.com ' }, { email: 'second@example.com' }], + data: [ + { id: 'suppression-1', email: ' First@Example.com ' }, + { id: 'suppression-2', email: 'second@example.com' }, + ], has_more: false, }) ) @@ -135,13 +138,42 @@ describe('newsletter Resend service', () => { const emails = await getResendSuppressedEmails() expect(emails).toEqual(new Set(['first@example.com', 'second@example.com'])) + expect(fetchMock).toHaveBeenCalledWith( + 'https://api.resend.com/suppressions?limit=100', + expect.objectContaining({ method: 'GET' }) + ) + }) + + it('paginates through all suppressed email addresses', async () => { + fetchMock + .mockResolvedValueOnce( + jsonResponse({ + data: [{ id: 'suppression-1', email: 'first@example.com' }], + has_more: true, + }) + ) + .mockResolvedValueOnce( + jsonResponse({ + data: [{ id: 'suppression-2', email: 'second@example.com' }], + has_more: false, + }) + ) + + const emails = await getResendSuppressedEmails() + + expect(emails).toEqual(new Set(['first@example.com', 'second@example.com'])) + expect(fetchMock).toHaveBeenNthCalledWith( + 2, + 'https://api.resend.com/suppressions?limit=100&after=suppression-1', + expect.objectContaining({ method: 'GET' }) + ) }) it('combines suppressions with globally unsubscribed contacts', async () => { fetchMock .mockResolvedValueOnce( jsonResponse({ - data: [{ email: 'suppressed@example.com' }], + data: [{ id: 'suppression-1', email: 'suppressed@example.com' }], has_more: false, }) ) diff --git a/apps/sim/lib/newsletters/resend.ts b/apps/sim/lib/newsletters/resend.ts index 14dfa2cf31b..37f0c2e90e3 100644 --- a/apps/sim/lib/newsletters/resend.ts +++ b/apps/sim/lib/newsletters/resend.ts @@ -13,6 +13,8 @@ const RESEND_CONTACT_PROPERTY_PAGE_LIMIT = 100 const RESEND_CONTACT_PROPERTY_MAX_PAGES = 10 const RESEND_CONTACT_PAGE_LIMIT = 100 const RESEND_CONTACT_MAX_PAGES = 1000 +const RESEND_SUPPRESSION_PAGE_LIMIT = 100 +const RESEND_SUPPRESSION_MAX_PAGES = 1000 const NEWSLETTER_CONTACT_PROPERTY_KEYS = ['sim_user_id', 'newsletter_run_id'] as const interface ResendErrorBody { @@ -43,7 +45,7 @@ interface ResendRequestOptions { } const resendSuppressionListSchema = z.object({ - data: z.array(z.object({ email: z.string().min(1) })), + data: z.array(z.object({ id: z.string().min(1), email: z.string().min(1) })), has_more: z.boolean(), }) @@ -116,22 +118,37 @@ async function resendRequest(path: string, options: ResendRequestOptions = {} } export async function getResendSuppressedEmails(options?: { required?: boolean }) { - const rawResponse = await resendRequest('/suppressions', { - required: options?.required ?? true, - }) - if (rawResponse === undefined) return new Set() - const parsedResponse = resendSuppressionListSchema.safeParse(rawResponse) - if (!parsedResponse.success) { - throw new Error('Resend suppression list response was malformed') - } - const response = parsedResponse.data const emails = new Set() - if (response?.has_more) { - throw new Error('Resend suppression list was incomplete') + let after: string | null = null + let hasMore = false + + for (let page = 0; page < RESEND_SUPPRESSION_MAX_PAGES; page++) { + const query = new URLSearchParams({ limit: String(RESEND_SUPPRESSION_PAGE_LIMIT) }) + if (after) query.set('after', after) + const rawResponse = await resendRequest(`/suppressions?${query.toString()}`, { + required: options?.required ?? true, + }) + if (rawResponse === undefined) return emails + const parsedResponse = resendSuppressionListSchema.safeParse(rawResponse) + if (!parsedResponse.success) { + throw new Error('Resend suppression list response was malformed') + } + const response = parsedResponse.data + + for (const suppression of response.data) { + emails.add(normalizeEmail(suppression.email)) + } + + hasMore = response.has_more + if (!hasMore) break + after = response.data.at(-1)?.id ?? null + if (!after) throw new Error('Resend suppression pagination returned no cursor') } - for (const suppression of response.data) { - emails.add(normalizeEmail(suppression.email)) + + if (hasMore) { + throw new Error('Resend suppression list exceeded the pagination safety limit') } + return emails } diff --git a/apps/sim/lib/newsletters/runs.test.ts b/apps/sim/lib/newsletters/runs.test.ts new file mode 100644 index 00000000000..1987a54ad34 --- /dev/null +++ b/apps/sim/lib/newsletters/runs.test.ts @@ -0,0 +1,80 @@ +/** + * @vitest-environment node + */ +import { newsletterAudienceRecipients, newsletterAudienceRuns } from '@sim/db/schema' +import { dbChainMockFns, queueTableRows, resetDbChainMock } from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@/lib/newsletters/resend', () => ({ + getResendExcludedEmails: vi.fn(), +})) + +import { + markNewsletterRunPushed, + resetFailedNewsletterRecipients, + updateRecipientSyncStatus, +} from '@/lib/newsletters/runs' + +afterAll(resetDbChainMock) + +describe('newsletter Resend state transitions', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + + it('locks the run while checking recipients and publishing', async () => { + queueTableRows(newsletterAudienceRuns, [ + { status: 'pushing', snapshotVersion: 3, resendSyncAttempt: 2 }, + ]) + queueTableRows(newsletterAudienceRecipients, []) + dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'run-1' }]) + + await markNewsletterRunPushed('run-1', 2, 'segment-1', 'Segment 1') + + expect(dbChainMockFns.transaction).toHaveBeenCalledOnce() + expect(dbChainMockFns.for).toHaveBeenCalledWith('update') + expect(dbChainMockFns.select).toHaveBeenCalledTimes(2) + }) + + it('rejects publishing when recipients remain incomplete', async () => { + queueTableRows(newsletterAudienceRuns, [ + { status: 'pushing', snapshotVersion: 3, resendSyncAttempt: 2 }, + ]) + queueTableRows(newsletterAudienceRecipients, [{ id: 'recipient-1' }]) + + await expect(markNewsletterRunPushed('run-1', 2, 'segment-1', 'Segment 1')).rejects.toThrow( + 'recipients remain pending or failed' + ) + + expect(dbChainMockFns.update).not.toHaveBeenCalled() + }) + + it('locks and attempt-fences failed-recipient resets', async () => { + queueTableRows(newsletterAudienceRuns, [ + { status: 'pushing', snapshotVersion: 3, resendSyncAttempt: 2 }, + ]) + + await resetFailedNewsletterRecipients('run-1', 2) + + expect(dbChainMockFns.transaction).toHaveBeenCalledOnce() + expect(dbChainMockFns.for).toHaveBeenCalledWith('update') + expect(dbChainMockFns.select).toHaveBeenCalledOnce() + expect(dbChainMockFns.update).toHaveBeenCalledOnce() + }) + + it('locks and attempt-fences recipient status updates', async () => { + queueTableRows(newsletterAudienceRuns, [ + { status: 'pushing', snapshotVersion: 3, resendSyncAttempt: 2 }, + ]) + + await updateRecipientSyncStatus('run-1', 2, 3, 'user@example.com', 'failed', { + error: 'Resend failed', + }) + + expect(dbChainMockFns.transaction).toHaveBeenCalledOnce() + expect(dbChainMockFns.for).toHaveBeenCalledWith('update') + expect(dbChainMockFns.select).toHaveBeenCalledOnce() + expect(dbChainMockFns.update).toHaveBeenCalledOnce() + }) +}) diff --git a/apps/sim/lib/newsletters/runs.ts b/apps/sim/lib/newsletters/runs.ts index 6be6968c39a..0dae8fcaf55 100644 --- a/apps/sim/lib/newsletters/runs.ts +++ b/apps/sim/lib/newsletters/runs.ts @@ -545,23 +545,61 @@ export async function markNewsletterRunPushed( segmentId: string, segmentName: string ) { - await db - .update(newsletterAudienceRuns) - .set({ - status: 'pushed', - resendSegmentId: segmentId, - resendSegmentName: segmentName, - resendSyncedAt: new Date(), - error: null, - updatedAt: new Date(), - }) - .where( - and( - eq(newsletterAudienceRuns.id, runId), - eq(newsletterAudienceRuns.resendSyncAttempt, attempt), - inArray(newsletterAudienceRuns.status, ['pushing', 'failed']) + await db.transaction(async (tx) => { + const [current] = await tx + .select({ + status: newsletterAudienceRuns.status, + snapshotVersion: newsletterAudienceRuns.snapshotVersion, + resendSyncAttempt: newsletterAudienceRuns.resendSyncAttempt, + }) + .from(newsletterAudienceRuns) + .where(eq(newsletterAudienceRuns.id, runId)) + .for('update') + .limit(1) + if (!current) throw new Error('Newsletter run not found') + if (current.resendSyncAttempt !== attempt) { + throw new Error('Newsletter Resend sync attempt was superseded') + } + if (current.status === 'pushed') return + if (current.status !== 'pushing' && current.status !== 'failed') { + throw new Error('Newsletter run is not eligible to complete its Resend sync') + } + + const [incompleteRecipient] = await tx + .select({ id: newsletterAudienceRecipients.id }) + .from(newsletterAudienceRecipients) + .where( + and( + eq(newsletterAudienceRecipients.runId, runId), + eq(newsletterAudienceRecipients.snapshotVersion, current.snapshotVersion), + inArray(newsletterAudienceRecipients.resendStatus, ['pending', 'failed']) + ) ) - ) + .limit(1) + if (incompleteRecipient) { + throw new Error('Newsletter sync incomplete: recipients remain pending or failed') + } + + const [updated] = await tx + .update(newsletterAudienceRuns) + .set({ + status: 'pushed', + resendSegmentId: segmentId, + resendSegmentName: segmentName, + resendSyncedAt: new Date(), + error: null, + updatedAt: new Date(), + }) + .where( + and( + eq(newsletterAudienceRuns.id, runId), + eq(newsletterAudienceRuns.resendSyncAttempt, attempt), + inArray(newsletterAudienceRuns.status, ['pushing', 'failed']) + ) + ) + .returning() + if (!updated) throw new Error('Newsletter Resend sync attempt was superseded') + }) } export async function setNewsletterRunResendSegment( @@ -659,13 +697,18 @@ export async function createNewsletterCsvExport( } } -export async function countNewsletterRecipientsByStatus(runId: string) { +export async function countNewsletterRecipientsByStatus(runId: string, attempt: number) { const [run] = await db .select({ snapshotVersion: newsletterAudienceRuns.snapshotVersion }) .from(newsletterAudienceRuns) - .where(eq(newsletterAudienceRuns.id, runId)) + .where( + and( + eq(newsletterAudienceRuns.id, runId), + eq(newsletterAudienceRuns.resendSyncAttempt, attempt) + ) + ) .limit(1) - if (!run) throw new Error('Newsletter run not found') + if (!run) throw new Error('Newsletter Resend sync attempt was superseded') const rows = await db .select({ @@ -686,35 +729,67 @@ export async function countNewsletterRecipientsByStatus(runId: string) { export async function updateRecipientSyncStatus( runId: string, + attempt: number, snapshotVersion: number, email: string, status: NewsletterRecipientSyncStatus, options?: { contactId?: string; error?: string | null } ) { - await db - .update(newsletterAudienceRecipients) - .set({ - resendStatus: status, - resendContactId: options?.contactId, - error: options?.error ?? null, - updatedAt: new Date(), - }) - .where( - and( - eq(newsletterAudienceRecipients.runId, runId), - eq(newsletterAudienceRecipients.snapshotVersion, snapshotVersion), - eq(newsletterAudienceRecipients.email, email) + await db.transaction(async (tx) => { + const [run] = await tx + .select({ + status: newsletterAudienceRuns.status, + snapshotVersion: newsletterAudienceRuns.snapshotVersion, + resendSyncAttempt: newsletterAudienceRuns.resendSyncAttempt, + }) + .from(newsletterAudienceRuns) + .where(eq(newsletterAudienceRuns.id, runId)) + .for('update') + .limit(1) + if ( + !run || + run.snapshotVersion !== snapshotVersion || + run.resendSyncAttempt !== attempt || + (run.status !== 'pushing' && run.status !== 'failed') + ) { + return + } + + await tx + .update(newsletterAudienceRecipients) + .set({ + resendStatus: status, + resendContactId: options?.contactId, + error: options?.error ?? null, + updatedAt: new Date(), + }) + .where( + and( + eq(newsletterAudienceRecipients.runId, runId), + eq(newsletterAudienceRecipients.snapshotVersion, snapshotVersion), + eq(newsletterAudienceRecipients.email, email) + ) ) - ) + }) } -export async function getPendingNewsletterRecipients(runId: string, limit: number) { +export async function getPendingNewsletterRecipients( + runId: string, + attempt: number, + limit: number +) { const [run] = await db .select({ snapshotVersion: newsletterAudienceRuns.snapshotVersion }) .from(newsletterAudienceRuns) - .where(eq(newsletterAudienceRuns.id, runId)) + .where( + and( + eq(newsletterAudienceRuns.id, runId), + eq(newsletterAudienceRuns.resendSyncAttempt, attempt), + inArray(newsletterAudienceRuns.status, ['pushing', 'failed']) + ) + ) .limit(1) - if (!run) throw new Error('Newsletter run not found') + if (!run) throw new Error('Newsletter Resend sync attempt was superseded') return db .select({ @@ -742,28 +817,41 @@ export async function getPendingNewsletterRecipients(runId: string, limit: numbe .limit(limit) } -export async function resetFailedNewsletterRecipients(runId: string) { - const [run] = await db - .select({ snapshotVersion: newsletterAudienceRuns.snapshotVersion }) - .from(newsletterAudienceRuns) - .where(eq(newsletterAudienceRuns.id, runId)) - .limit(1) - if (!run) throw new Error('Newsletter run not found') +export async function resetFailedNewsletterRecipients(runId: string, attempt: number) { + await db.transaction(async (tx) => { + const [run] = await tx + .select({ + status: newsletterAudienceRuns.status, + snapshotVersion: newsletterAudienceRuns.snapshotVersion, + resendSyncAttempt: newsletterAudienceRuns.resendSyncAttempt, + }) + .from(newsletterAudienceRuns) + .where(eq(newsletterAudienceRuns.id, runId)) + .for('update') + .limit(1) + if ( + !run || + run.resendSyncAttempt !== attempt || + (run.status !== 'pushing' && run.status !== 'failed') + ) { + return + } - await db - .update(newsletterAudienceRecipients) - .set({ - resendStatus: 'pending', - error: null, - updatedAt: new Date(), - }) - .where( - and( - eq(newsletterAudienceRecipients.runId, runId), - eq(newsletterAudienceRecipients.snapshotVersion, run.snapshotVersion), - eq(newsletterAudienceRecipients.resendStatus, 'failed') + await tx + .update(newsletterAudienceRecipients) + .set({ + resendStatus: 'pending', + error: null, + updatedAt: new Date(), + }) + .where( + and( + eq(newsletterAudienceRecipients.runId, runId), + eq(newsletterAudienceRecipients.snapshotVersion, run.snapshotVersion), + eq(newsletterAudienceRecipients.resendStatus, 'failed') + ) ) - ) + }) } export async function requireNewsletterRunAttempt(runId: string, attempt: number) { diff --git a/packages/testing/src/mocks/schema.mock.ts b/packages/testing/src/mocks/schema.mock.ts index 5cccc2c8af0..a704602434a 100644 --- a/packages/testing/src/mocks/schema.mock.ts +++ b/packages/testing/src/mocks/schema.mock.ts @@ -269,6 +269,45 @@ export const schemaMock = { lastActiveWorkspaceId: 'lastActiveWorkspaceId', updatedAt: 'updatedAt', }, + newsletterAudienceRuns: { + id: 'id', + createdById: 'createdById', + name: 'name', + prompt: 'prompt', + criteria: 'criteria', + status: 'status', + totalMatched: 'totalMatched', + excludedBanned: 'excludedBanned', + excludedUnverified: 'excludedUnverified', + excludedUnsubscribed: 'excludedUnsubscribed', + excludedSuppressed: 'excludedSuppressed', + finalRecipientCount: 'finalRecipientCount', + sampleRecipients: 'sampleRecipients', + resendSegmentId: 'resendSegmentId', + resendSegmentName: 'resendSegmentName', + resendSyncedAt: 'resendSyncedAt', + snapshotVersion: 'snapshotVersion', + resendSyncAttempt: 'resendSyncAttempt', + resendSyncJobId: 'resendSyncJobId', + error: 'error', + finalizedAt: 'finalizedAt', + createdAt: 'createdAt', + updatedAt: 'updatedAt', + }, + newsletterAudienceRecipients: { + id: 'id', + runId: 'runId', + userId: 'userId', + email: 'email', + name: 'name', + inclusionReason: 'inclusionReason', + snapshotVersion: 'snapshotVersion', + resendContactId: 'resendContactId', + resendStatus: 'resendStatus', + error: 'error', + createdAt: 'createdAt', + updatedAt: 'updatedAt', + }, workflowSchedule: { id: 'id', workflowId: 'workflowId', From 928a7c97d3412b99da1784617ca8acded31393a5 Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Wed, 29 Jul 2026 18:20:11 -0700 Subject: [PATCH 3/5] fix(newsletters): propagate sync cancellation --- .../background/newsletter-resend-sync.test.ts | 40 +++++++++++ apps/sim/background/newsletter-resend-sync.ts | 3 +- apps/sim/lib/newsletters/push-resend.test.ts | 43 +++++++++++- apps/sim/lib/newsletters/push-resend.ts | 8 ++- apps/sim/lib/newsletters/resend.test.ts | 66 ++++++++++++++++++- apps/sim/lib/newsletters/resend.ts | 38 +++++++++-- 6 files changed, 186 insertions(+), 12 deletions(-) create mode 100644 apps/sim/background/newsletter-resend-sync.test.ts diff --git a/apps/sim/background/newsletter-resend-sync.test.ts b/apps/sim/background/newsletter-resend-sync.test.ts new file mode 100644 index 00000000000..2c6ce7c7992 --- /dev/null +++ b/apps/sim/background/newsletter-resend-sync.test.ts @@ -0,0 +1,40 @@ +/** + * @vitest-environment node + */ +import { expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + runSync: vi.fn(), + task: vi.fn((definition) => definition), +})) + +vi.mock('@trigger.dev/sdk', () => ({ + task: mocks.task, +})) + +vi.mock('@/lib/newsletters/push-resend', () => ({ + NEWSLETTER_RESEND_SYNC_CONCURRENCY_LIMIT: 1, + NEWSLETTER_RESEND_SYNC_MAX_ATTEMPTS: 3, + runNewsletterResendSync: mocks.runSync, +})) + +import type { NewsletterResendSyncPayload } from '@/lib/newsletters/push-resend' +import { newsletterResendSyncTask } from '@/background/newsletter-resend-sync' + +interface NewsletterTaskDefinition { + run: (payload: NewsletterResendSyncPayload, context: { signal: AbortSignal }) => Promise +} + +it('forwards the Trigger.dev cancellation signal to the sync service', async () => { + const payload = { + runId: 'run-1', + attempt: 1, + requestedById: 'admin-1', + } + const controller = new AbortController() + const definition = newsletterResendSyncTask as unknown as NewsletterTaskDefinition + + await definition.run(payload, { signal: controller.signal }) + + expect(mocks.runSync).toHaveBeenCalledWith(payload, controller.signal) +}) diff --git a/apps/sim/background/newsletter-resend-sync.ts b/apps/sim/background/newsletter-resend-sync.ts index 860a8326d79..ae017a02672 100644 --- a/apps/sim/background/newsletter-resend-sync.ts +++ b/apps/sim/background/newsletter-resend-sync.ts @@ -15,5 +15,6 @@ export const newsletterResendSyncTask = task({ queue: { concurrencyLimit: NEWSLETTER_RESEND_SYNC_CONCURRENCY_LIMIT, }, - run: async (payload: NewsletterResendSyncPayload) => runNewsletterResendSync(payload), + run: async (payload: NewsletterResendSyncPayload, { signal }) => + runNewsletterResendSync(payload, signal), }) diff --git a/apps/sim/lib/newsletters/push-resend.test.ts b/apps/sim/lib/newsletters/push-resend.test.ts index db6c6f5d782..26afc7aad34 100644 --- a/apps/sim/lib/newsletters/push-resend.test.ts +++ b/apps/sim/lib/newsletters/push-resend.test.ts @@ -6,6 +6,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ claimAttempt: vi.fn(), countByStatus: vi.fn(), + createContact: vi.fn(), createSegment: vi.fn(), ensureProperties: vi.fn(), getAsyncBackendType: vi.fn(), @@ -39,7 +40,7 @@ vi.mock('@/lib/core/async-jobs', () => ({ vi.mock('@/lib/newsletters/resend', () => ({ createNewsletterSegment: mocks.createSegment, - createOrSegmentNewsletterContact: vi.fn(), + createOrSegmentNewsletterContact: mocks.createContact, ensureNewsletterContactProperties: mocks.ensureProperties, getResendExcludedEmails: mocks.getExcludedEmails, })) @@ -387,6 +388,46 @@ describe('newsletter Resend queueing', () => { expect(mocks.markFailed).not.toHaveBeenCalled() }) + it('forwards cancellation to every Resend operation', async () => { + const controller = new AbortController() + const recipient = { + currentUserEligible: true, + email: 'user@example.com', + name: 'Ada Lovelace', + simUnsubscribed: false, + snapshotVersion: 1, + userId: 'user-1', + } + mocks.requireAttempt.mockResolvedValue({ + ...run, + resendSegmentId: null, + resendSegmentName: null, + }) + mocks.createSegment.mockResolvedValue({ id: 'segment-new', name: 'Segment new' }) + mocks.getExcludedEmails.mockResolvedValue(new Set()) + mocks.getPendingRecipients.mockResolvedValueOnce([recipient]).mockResolvedValueOnce([]) + mocks.createContact.mockResolvedValue({ status: 'created', contactId: 'contact-1' }) + mocks.countByStatus.mockResolvedValue({}) + + await runNewsletterResendSync( + { + runId: 'run-1', + attempt: 2, + requestedById: 'admin-1', + }, + controller.signal + ) + + expect(mocks.createSegment).toHaveBeenCalledWith(expect.any(String), { + signal: controller.signal, + }) + expect(mocks.ensureProperties).toHaveBeenCalledWith({ signal: controller.signal }) + expect(mocks.getExcludedEmails).toHaveBeenCalledWith({ signal: controller.signal }) + expect(mocks.createContact).toHaveBeenCalledWith( + expect.objectContaining({ signal: controller.signal }) + ) + }) + it('does not mark a run pushed after ownership is lost during status aggregation', async () => { const controller = new AbortController() mocks.requireAttempt.mockResolvedValue(run) diff --git a/apps/sim/lib/newsletters/push-resend.ts b/apps/sim/lib/newsletters/push-resend.ts index 314693a0a20..57bdeb9b73d 100644 --- a/apps/sim/lib/newsletters/push-resend.ts +++ b/apps/sim/lib/newsletters/push-resend.ts @@ -55,6 +55,7 @@ async function runWithConcurrency( for (let index = 0; index < items.length; index += limit) { signal?.throwIfAborted() await Promise.all(items.slice(index, index + limit).map(worker)) + signal?.throwIfAborted() } } @@ -79,7 +80,7 @@ export async function runNewsletterResendSync( const segment = run.resendSegmentId && run.resendSegmentName ? { id: run.resendSegmentId, name: run.resendSegmentName } - : await createNewsletterSegment(segmentNameForRun(run.name)) + : await createNewsletterSegment(segmentNameForRun(run.name), { signal }) signal?.throwIfAborted() if (!run.resendSegmentId) { @@ -87,9 +88,9 @@ export async function runNewsletterResendSync( } signal?.throwIfAborted() - await ensureNewsletterContactProperties() + await ensureNewsletterContactProperties({ signal }) signal?.throwIfAborted() - const suppressedEmails = await getResendExcludedEmails() + const suppressedEmails = await getResendExcludedEmails({ signal }) let processed = 0 while (true) { @@ -128,6 +129,7 @@ export async function runNewsletterResendSync( userId: recipient.userId, runId, segmentId: segment.id, + signal, }) signal?.throwIfAborted() await updateRecipientSyncStatus( diff --git a/apps/sim/lib/newsletters/resend.test.ts b/apps/sim/lib/newsletters/resend.test.ts index 65b257d2725..43b6fcb4408 100644 --- a/apps/sim/lib/newsletters/resend.test.ts +++ b/apps/sim/lib/newsletters/resend.test.ts @@ -3,15 +3,21 @@ */ import { beforeEach, describe, expect, it, vi } from 'vitest' -const { fetchMock } = vi.hoisted(() => ({ +const { fetchMock, sleepMock } = vi.hoisted(() => ({ fetchMock: vi.fn(), + sleepMock: vi.fn(), })) vi.mock('@/lib/core/config/env', () => ({ env: { RESEND_API_KEY: 're_test' }, })) +vi.mock('@sim/utils/helpers', () => ({ + sleep: sleepMock, +})) + import { + createNewsletterSegment, createOrSegmentNewsletterContact, ensureNewsletterContactProperties, getResendExcludedEmails, @@ -29,6 +35,42 @@ describe('newsletter Resend service', () => { beforeEach(() => { vi.clearAllMocks() vi.stubGlobal('fetch', fetchMock) + sleepMock.mockResolvedValue(undefined) + }) + + it('forwards cancellation to Resend requests', async () => { + const controller = new AbortController() + fetchMock.mockResolvedValueOnce(jsonResponse({ id: 'segment-1', name: 'Segment 1' }, 201)) + + await createNewsletterSegment('Segment 1', { signal: controller.signal }) + + expect(fetchMock).toHaveBeenCalledWith( + 'https://api.resend.com/segments', + expect.objectContaining({ signal: controller.signal }) + ) + }) + + it('does not make a Resend request when already aborted', async () => { + const controller = new AbortController() + controller.abort('cancelled') + + await expect(createNewsletterSegment('Segment 1', { signal: controller.signal })).rejects.toBe( + 'cancelled' + ) + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('does not retry after cancellation during backoff', async () => { + const controller = new AbortController() + fetchMock.mockResolvedValueOnce(jsonResponse({ message: 'retry' }, 429)) + sleepMock.mockImplementationOnce(async () => { + controller.abort('cancelled') + }) + + await expect(createNewsletterSegment('Segment 1', { signal: controller.signal })).rejects.toBe( + 'cancelled' + ) + expect(fetchMock).toHaveBeenCalledTimes(1) }) it('creates missing newsletter contact properties', async () => { @@ -124,6 +166,28 @@ describe('newsletter Resend service', () => { ) }) + it('does not add segment membership after cancellation', async () => { + const controller = new AbortController() + fetchMock + .mockResolvedValueOnce(jsonResponse({ message: 'Contact already exists' }, 409)) + .mockImplementationOnce(async () => { + controller.abort('cancelled') + return jsonResponse({ id: 'contact-1' }) + }) + + await expect( + createOrSegmentNewsletterContact({ + email: 'user@example.com', + name: 'Ada Lovelace', + userId: 'user-1', + runId: 'run-1', + segmentId: 'segment-1', + signal: controller.signal, + }) + ).rejects.toBe('cancelled') + expect(fetchMock).toHaveBeenCalledTimes(2) + }) + it('normalizes suppressed email addresses', async () => { fetchMock.mockResolvedValueOnce( jsonResponse({ diff --git a/apps/sim/lib/newsletters/resend.ts b/apps/sim/lib/newsletters/resend.ts index 37f0c2e90e3..626e2c60fc5 100644 --- a/apps/sim/lib/newsletters/resend.ts +++ b/apps/sim/lib/newsletters/resend.ts @@ -42,6 +42,7 @@ interface ResendRequestOptions { body?: Record required?: boolean maxResponseBytes?: number + signal?: AbortSignal } const resendSuppressionListSchema = z.object({ @@ -88,6 +89,7 @@ async function resendRequest(path: string, options: ResendRequestOptions = {} if (!key) return undefined as T for (let attempt = 0; attempt < RESEND_MAX_ATTEMPTS; attempt++) { + options.signal?.throwIfAborted() const response = await fetch(`${RESEND_API_BASE}${path}`, { method: options.method ?? 'GET', headers: { @@ -95,6 +97,7 @@ async function resendRequest(path: string, options: ResendRequestOptions = {} 'Content-Type': 'application/json', }, body: options.body ? JSON.stringify(options.body) : undefined, + signal: options.signal, }) if (response.ok) { @@ -108,6 +111,7 @@ async function resendRequest(path: string, options: ResendRequestOptions = {} if (response.status === 429 || response.status >= 500) { const retryAfterMs = parseRetryAfter(response.headers.get('retry-after')) await sleep(backoffWithJitter(attempt + 1, retryAfterMs, { baseMs: 250, maxMs: 5000 })) + options.signal?.throwIfAborted() continue } @@ -117,7 +121,10 @@ async function resendRequest(path: string, options: ResendRequestOptions = {} throw new Error('Resend request failed after retries') } -export async function getResendSuppressedEmails(options?: { required?: boolean }) { +export async function getResendSuppressedEmails(options?: { + required?: boolean + signal?: AbortSignal +}) { const emails = new Set() let after: string | null = null let hasMore = false @@ -127,6 +134,7 @@ export async function getResendSuppressedEmails(options?: { required?: boolean } if (after) query.set('after', after) const rawResponse = await resendRequest(`/suppressions?${query.toString()}`, { required: options?.required ?? true, + signal: options?.signal, }) if (rawResponse === undefined) return emails const parsedResponse = resendSuppressionListSchema.safeParse(rawResponse) @@ -152,8 +160,13 @@ export async function getResendSuppressedEmails(options?: { required?: boolean } return emails } -export async function getResendExcludedEmails(): Promise> { - const excludedEmails = await getResendSuppressedEmails({ required: true }) +export async function getResendExcludedEmails(options?: { + signal?: AbortSignal +}): Promise> { + const excludedEmails = await getResendSuppressedEmails({ + required: true, + signal: options?.signal, + }) let after: string | null = null let hasMore = false @@ -162,6 +175,7 @@ export async function getResendExcludedEmails(): Promise> { if (after) query.set('after', after) const rawContacts = await resendRequest(`/contacts?${query.toString()}`, { required: true, + signal: options?.signal, }) const parsedContacts = resendContactListSchema.safeParse(rawContacts) if (!parsedContacts.success) { @@ -188,14 +202,20 @@ export async function getResendExcludedEmails(): Promise> { return excludedEmails } -export async function createNewsletterSegment(name: string): Promise { +export async function createNewsletterSegment( + name: string, + options?: { signal?: AbortSignal } +): Promise { return resendRequest('/segments', { method: 'POST', body: { name }, + signal: options?.signal, }) } -export async function ensureNewsletterContactProperties(): Promise { +export async function ensureNewsletterContactProperties(options?: { + signal?: AbortSignal +}): Promise { const existingKeys = new Set() let after: string | null = null let hasMore = false @@ -204,7 +224,8 @@ export async function ensureNewsletterContactProperties(): Promise { const query = new URLSearchParams({ limit: String(RESEND_CONTACT_PROPERTY_PAGE_LIMIT) }) if (after) query.set('after', after) const response = await resendRequest( - `/contact-properties?${query.toString()}` + `/contact-properties?${query.toString()}`, + { signal: options?.signal } ) for (const property of response.data ?? []) { @@ -228,6 +249,7 @@ export async function ensureNewsletterContactProperties(): Promise { await resendRequest('/contact-properties', { method: 'POST', body: { key, type: 'string' }, + signal: options?.signal, }) } catch (error) { const message = getErrorMessage(error) @@ -252,6 +274,7 @@ export async function createOrSegmentNewsletterContact(input: { userId: string | null runId: string segmentId: string + signal?: AbortSignal }): Promise<{ status: 'created' | 'updated' | 'segment_added'; contactId?: string }> { const email = normalizeEmail(input.email) const name = splitName(input.name) @@ -269,6 +292,7 @@ export async function createOrSegmentNewsletterContact(input: { }, segments: [{ id: input.segmentId }], }, + signal: input.signal, }) return { status: 'created', contactId: contact.id } } catch (error) { @@ -286,10 +310,12 @@ export async function createOrSegmentNewsletterContact(input: { newsletter_run_id: input.runId, }, }, + signal: input.signal, } ) await resendRequest(`/contacts/${encodeURIComponent(email)}/segments/${input.segmentId}`, { method: 'POST', + signal: input.signal, }) return { status: 'updated', contactId: contact.id } } From 07ff1c5de301c36afddaac3843b15dd3fe093fc4 Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Wed, 29 Jul 2026 18:49:44 -0700 Subject: [PATCH 4/5] fix(newsletters): keep exports and recovery responsive --- .../core/async-jobs/backends/database.test.ts | 42 +++++++++- .../lib/core/async-jobs/backends/database.ts | 13 +++ apps/sim/lib/newsletters/runs.test.ts | 79 ++++++++++++++++++- apps/sim/lib/newsletters/runs.ts | 7 +- 4 files changed, 134 insertions(+), 7 deletions(-) diff --git a/apps/sim/lib/core/async-jobs/backends/database.test.ts b/apps/sim/lib/core/async-jobs/backends/database.test.ts index d09700eb6b6..e4d0ee80d43 100644 --- a/apps/sim/lib/core/async-jobs/backends/database.test.ts +++ b/apps/sim/lib/core/async-jobs/backends/database.test.ts @@ -227,9 +227,9 @@ describe('DatabaseJobQueue enqueue', () => { let runnerSignal: AbortSignal | undefined const runner = vi.fn( async (_payload: unknown, signal: AbortSignal) => - new Promise((resolve) => { + new Promise((_resolve, reject) => { runnerSignal = signal - signal.addEventListener('abort', () => resolve(), { once: true }) + signal.addEventListener('abort', () => reject(new Error('claim lost')), { once: true }) }) ) const queue = new DatabaseJobQueue() @@ -242,6 +242,12 @@ describe('DatabaseJobQueue enqueue', () => { await vi.advanceTimersByTimeAsync(30_000) expect(runnerSignal?.aborted).toBe(true) + expect(dbChainMockFns.set).not.toHaveBeenCalledWith( + expect.objectContaining({ status: 'failed' }) + ) + expect(dbChainMockFns.set).not.toHaveBeenCalledWith( + expect.objectContaining({ status: 'completed' }) + ) } finally { vi.useRealTimers() } @@ -271,10 +277,42 @@ describe('DatabaseJobQueue enqueue', () => { await vi.advanceTimersByTimeAsync(120_000) expect(runnerSignal?.aborted).toBe(true) + expect(dbChainMockFns.set).not.toHaveBeenCalledWith( + expect.objectContaining({ status: 'failed' }) + ) + expect(dbChainMockFns.set).not.toHaveBeenCalledWith( + expect.objectContaining({ status: 'completed' }) + ) } finally { vi.useRealTimers() } }) + + it('keeps explicit cancellation terminal while the runner stops cooperatively', async () => { + dbChainMockFns.returning.mockResolvedValueOnce([{ payload: EXISTING_JOB.payload }]) + const runner = vi.fn( + async (_payload: unknown, signal: AbortSignal) => + new Promise((_resolve, reject) => { + signal.addEventListener('abort', () => reject(new Error('cancelled')), { once: true }) + }) + ) + const queue = new DatabaseJobQueue() + + await queue.enqueue( + 'workflow-execution', + { executionId: 'execution-1' }, + { jobId: 'workflow:1', runner } + ) + await queue.cancelJob('workflow:1') + await sleep(1) + + expect(dbChainMockFns.set).toHaveBeenCalledWith( + expect.objectContaining({ status: 'failed', error: 'Cancelled' }) + ) + expect(dbChainMockFns.set).not.toHaveBeenCalledWith( + expect.objectContaining({ status: 'completed' }) + ) + }) }) describe('DatabaseJobQueue batchEnqueueAndWait', () => { diff --git a/apps/sim/lib/core/async-jobs/backends/database.ts b/apps/sim/lib/core/async-jobs/backends/database.ts index b4029223f59..18c48d06706 100644 --- a/apps/sim/lib/core/async-jobs/backends/database.ts +++ b/apps/sim/lib/core/async-jobs/backends/database.ts @@ -533,8 +533,21 @@ export class DatabaseJobQueue implements JobQueueBackend { try { try { await runner(claim.payload, abortController.signal) + if (Date.now() >= leaseValidUntil) { + abortController.abort('Inline job claim expired') + } + abortController.signal.throwIfAborted() await this.completeInlineJob(jobId, claimToken) } catch (error) { + if (Date.now() >= leaseValidUntil) { + abortController.abort('Inline job claim expired') + } + if (abortController.signal.aborted) { + logger.info(`[${type}] Inline job ${jobId} stopped after ownership ended`, { + reason: toError(abortController.signal.reason).message, + }) + return + } const message = toError(error).message logger.error(`[${type}] Inline job ${jobId} failed`, { error: message }) try { diff --git a/apps/sim/lib/newsletters/runs.test.ts b/apps/sim/lib/newsletters/runs.test.ts index 1987a54ad34..318d4a5125a 100644 --- a/apps/sim/lib/newsletters/runs.test.ts +++ b/apps/sim/lib/newsletters/runs.test.ts @@ -5,16 +5,47 @@ import { newsletterAudienceRecipients, newsletterAudienceRuns } from '@sim/db/sc import { dbChainMockFns, queueTableRows, resetDbChainMock } from '@sim/testing' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' -vi.mock('@/lib/newsletters/resend', () => ({ +const mocks = vi.hoisted(() => ({ getResendExcludedEmails: vi.fn(), })) +vi.mock('@/lib/newsletters/resend', () => ({ + getResendExcludedEmails: mocks.getResendExcludedEmails, +})) + import { + createNewsletterCsvExport, markNewsletterRunPushed, resetFailedNewsletterRecipients, updateRecipientSyncStatus, } from '@/lib/newsletters/runs' +const finalizedRun = { + id: 'run-1', + createdById: 'admin-1', + name: 'Launch', + prompt: 'Everyone', + criteria: { type: 'everyone' }, + status: 'finalized', + totalMatched: 1, + excludedBanned: 0, + excludedUnverified: 0, + excludedUnsubscribed: 0, + excludedSuppressed: 0, + finalRecipientCount: 1, + sampleRecipients: [], + resendSegmentId: null, + resendSegmentName: null, + resendSyncedAt: null, + snapshotVersion: 1, + resendSyncAttempt: 0, + resendSyncJobId: null, + error: null, + finalizedAt: new Date('2026-07-29T00:00:00.000Z'), + createdAt: new Date('2026-07-29T00:00:00.000Z'), + updatedAt: new Date('2026-07-29T00:00:00.000Z'), +} + afterAll(resetDbChainMock) describe('newsletter Resend state transitions', () => { @@ -78,3 +109,49 @@ describe('newsletter Resend state transitions', () => { expect(dbChainMockFns.update).toHaveBeenCalledOnce() }) }) + +describe('newsletter CSV export', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + queueTableRows(newsletterAudienceRuns, [finalizedRun]) + queueTableRows(newsletterAudienceRuns, [{ snapshotVersion: 1 }]) + }) + + it('streams the header before scanning Resend exclusions', async () => { + let resolveExclusions: ((value: Set) => void) | undefined + mocks.getResendExcludedEmails.mockImplementation( + () => + new Promise>((resolve) => { + resolveExclusions = resolve + }) + ) + queueTableRows(newsletterAudienceRecipients, []) + + const { lines } = await createNewsletterCsvExport('run-1') + + expect(mocks.getResendExcludedEmails).not.toHaveBeenCalled() + await expect(lines.next()).resolves.toEqual({ + done: false, + value: 'email,first_name,last_name,sim_user_id,inclusion_reason', + }) + expect(mocks.getResendExcludedEmails).not.toHaveBeenCalled() + + const nextLine = lines.next() + await Promise.resolve() + expect(mocks.getResendExcludedEmails).toHaveBeenCalledOnce() + + resolveExclusions?.(new Set()) + await expect(nextLine).resolves.toEqual({ done: true, value: undefined }) + }) + + it('fails closed before querying or emitting recipient rows when Resend fails', async () => { + mocks.getResendExcludedEmails.mockRejectedValueOnce(new Error('Resend unavailable')) + const { lines } = await createNewsletterCsvExport('run-1') + + await expect(lines.next()).resolves.toMatchObject({ done: false }) + await expect(lines.next()).rejects.toThrow('Resend unavailable') + + expect(dbChainMockFns.select).toHaveBeenCalledTimes(2) + }) +}) diff --git a/apps/sim/lib/newsletters/runs.ts b/apps/sim/lib/newsletters/runs.ts index 0dae8fcaf55..c9a74cb1e61 100644 --- a/apps/sim/lib/newsletters/runs.ts +++ b/apps/sim/lib/newsletters/runs.ts @@ -627,10 +627,10 @@ const NEWSLETTER_CSV_PAGE_SIZE = 1000 async function* generateNewsletterCsvLines( runId: string, - runSnapshotVersion: number, - resendExcludedEmails: Set + runSnapshotVersion: number ): AsyncGenerator { yield toCsvRow(['email', 'first_name', 'last_name', 'sim_user_id', 'inclusion_reason']) + const resendExcludedEmails = await getResendExcludedEmails() let afterEmail: string | null = null while (true) { @@ -689,11 +689,10 @@ export async function createNewsletterCsvExport( .where(eq(newsletterAudienceRuns.id, runId)) .limit(1) if (!row) throw new Error('Newsletter run not found') - const resendExcludedEmails = await getResendExcludedEmails() return { filename: `newsletter-${run.name.replace(/[^a-zA-Z0-9_-]+/g, '_')}-${run.id.slice(0, 8)}.csv`, - lines: generateNewsletterCsvLines(runId, row.snapshotVersion, resendExcludedEmails), + lines: generateNewsletterCsvLines(runId, row.snapshotVersion), } } From 493375bc3abd7c97e74342bb7e57a43de247f25e Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Thu, 30 Jul 2026 03:50:32 -0700 Subject: [PATCH 5/5] fix(newsletters): fence resend recovery state --- .../newsletters/runs/[id]/export.csv/route.ts | 3 +- .../runs/[id]/finalize/route.test.ts | 63 +++++++++ .../newsletters/runs/[id]/finalize/route.ts | 3 +- apps/sim/lib/api/contracts/newsletters.ts | 2 +- apps/sim/lib/newsletters/push-resend.test.ts | 123 ++++++++++++++++++ apps/sim/lib/newsletters/push-resend.ts | 47 +++++-- apps/sim/lib/newsletters/resend.test.ts | 22 ++++ apps/sim/lib/newsletters/resend.ts | 83 ++++++++---- apps/sim/lib/newsletters/runs.test.ts | 121 +++++++++++++++++ apps/sim/lib/newsletters/runs.ts | 98 ++++++++++---- 10 files changed, 499 insertions(+), 66 deletions(-) create mode 100644 apps/sim/app/api/superuser/newsletters/runs/[id]/finalize/route.test.ts diff --git a/apps/sim/app/api/superuser/newsletters/runs/[id]/export.csv/route.ts b/apps/sim/app/api/superuser/newsletters/runs/[id]/export.csv/route.ts index 5a5b959d01d..d001d775d64 100644 --- a/apps/sim/app/api/superuser/newsletters/runs/[id]/export.csv/route.ts +++ b/apps/sim/app/api/superuser/newsletters/runs/[id]/export.csv/route.ts @@ -5,6 +5,7 @@ import { exportNewsletterRunCsvContract } from '@/lib/api/contracts/newsletters' import { parseRequest } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { validateNewsletterSuperuser } from '@/lib/newsletters/auth' +import { isNewsletterResendError } from '@/lib/newsletters/resend' import { createNewsletterCsvExport } from '@/lib/newsletters/runs' const logger = createLogger('NewsletterCsvExportAPI') @@ -58,7 +59,7 @@ export const GET = withRouteHandler( if (/Finalize/i.test(message)) { return NextResponse.json({ error: message }, { status: 400 }) } - if (/RESEND_API_KEY|Resend .*list|Resend request/i.test(message)) { + if (isNewsletterResendError(error)) { return NextResponse.json({ error: message }, { status: 503 }) } logger.error('Failed to export newsletter CSV', { error: message }) diff --git a/apps/sim/app/api/superuser/newsletters/runs/[id]/finalize/route.test.ts b/apps/sim/app/api/superuser/newsletters/runs/[id]/finalize/route.test.ts new file mode 100644 index 00000000000..edfd7212d58 --- /dev/null +++ b/apps/sim/app/api/superuser/newsletters/runs/[id]/finalize/route.test.ts @@ -0,0 +1,63 @@ +/** + * @vitest-environment node + */ +import { createMockRequest } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockFinalizeNewsletterRun, mockValidateNewsletterSuperuser } = vi.hoisted(() => ({ + mockFinalizeNewsletterRun: vi.fn(), + mockValidateNewsletterSuperuser: vi.fn(), +})) + +vi.mock('@/lib/newsletters/auth', () => ({ + validateNewsletterSuperuser: mockValidateNewsletterSuperuser, +})) + +vi.mock('@/lib/newsletters/runs', () => ({ + finalizeNewsletterRun: mockFinalizeNewsletterRun, +})) + +import { NewsletterResendError } from '@/lib/newsletters/resend' +import { POST } from '@/app/api/superuser/newsletters/runs/[id]/finalize/route' + +function callRoute() { + const request = createMockRequest( + 'POST', + undefined, + {}, + 'http://localhost:3000/api/superuser/newsletters/runs/run-1/finalize' + ) + return POST(request, { params: Promise.resolve({ id: 'run-1' }) }) +} + +describe('newsletter run finalization', () => { + beforeEach(() => { + vi.clearAllMocks() + mockValidateNewsletterSuperuser.mockResolvedValue({ + success: true, + userId: 'admin-1', + }) + }) + + it.each([ + 'Resend suppression pagination returned no cursor', + 'Resend contact pagination returned no cursor', + 'Resend contact property pagination returned no cursor', + ])('maps a Resend service failure to 503: %s', async (message) => { + mockFinalizeNewsletterRun.mockRejectedValueOnce(new NewsletterResendError(message)) + + const response = await callRoute() + + expect(response.status).toBe(503) + await expect(response.json()).resolves.toEqual({ error: message }) + }) + + it('does not classify an unrelated error by message text', async () => { + mockFinalizeNewsletterRun.mockRejectedValueOnce(new Error('Resend text from unrelated code')) + + const response = await callRoute() + + expect(response.status).toBe(500) + await expect(response.json()).resolves.toEqual({ error: 'Internal server error' }) + }) +}) diff --git a/apps/sim/app/api/superuser/newsletters/runs/[id]/finalize/route.ts b/apps/sim/app/api/superuser/newsletters/runs/[id]/finalize/route.ts index 2b10eee56c9..63d1c5c3c4b 100644 --- a/apps/sim/app/api/superuser/newsletters/runs/[id]/finalize/route.ts +++ b/apps/sim/app/api/superuser/newsletters/runs/[id]/finalize/route.ts @@ -5,6 +5,7 @@ import { finalizeNewsletterRunContract } from '@/lib/api/contracts/newsletters' import { parseRequest } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { validateNewsletterSuperuser } from '@/lib/newsletters/auth' +import { isNewsletterResendError } from '@/lib/newsletters/resend' import { finalizeNewsletterRun } from '@/lib/newsletters/runs' const logger = createLogger('NewsletterFinalizeAPI') @@ -35,7 +36,7 @@ export const POST = withRouteHandler( if (/already in progress/i.test(message)) { return NextResponse.json({ error: message }, { status: 409 }) } - if (/RESEND_API_KEY|Resend .*list|Resend request/i.test(message)) { + if (isNewsletterResendError(error)) { return NextResponse.json({ error: message }, { status: 503 }) } logger.error('Failed to finalize newsletter run', { error: message }) diff --git a/apps/sim/lib/api/contracts/newsletters.ts b/apps/sim/lib/api/contracts/newsletters.ts index f187301a26e..1ec696406f6 100644 --- a/apps/sim/lib/api/contracts/newsletters.ts +++ b/apps/sim/lib/api/contracts/newsletters.ts @@ -114,7 +114,7 @@ export const newsletterRunResponseSchema = z.object({ export const pushNewsletterRunResponseSchema = z.object({ run: newsletterRunSchema, - jobId: z.string(), + jobId: z.string().nullable(), }) export const newsletterJobResponseSchema = z.object({ diff --git a/apps/sim/lib/newsletters/push-resend.test.ts b/apps/sim/lib/newsletters/push-resend.test.ts index 26afc7aad34..9bf23613723 100644 --- a/apps/sim/lib/newsletters/push-resend.test.ts +++ b/apps/sim/lib/newsletters/push-resend.test.ts @@ -58,6 +58,7 @@ vi.mock('@/lib/newsletters/runs', () => ({ updateRecipientSyncStatus: mocks.updateRecipient, })) +import { pushNewsletterRunResponseSchema } from '@/lib/api/contracts/newsletters' import { enqueueNewsletterResendSync, runNewsletterResendSync } from '@/lib/newsletters/push-resend' const run = { @@ -86,6 +87,7 @@ describe('newsletter Resend queueing', () => { }) mocks.queueEnqueue.mockResolvedValue('trigger-run-123') mocks.setJob.mockResolvedValue({ ...run, resendSyncJobId: 'trigger-run-123' }) + mocks.setSegment.mockResolvedValue(run) mocks.isAsyncJobEnqueueError.mockReturnValue(false) }) @@ -238,6 +240,55 @@ describe('newsletter Resend queueing', () => { } ) + it('does not enqueue a pushed run without a stored job id', async () => { + const pushedRun = { ...run, status: 'pushed' } + mocks.claimAttempt.mockResolvedValue({ + attempt: 2, + jobId: null, + run: pushedRun, + shouldEnqueue: false, + }) + + const result = await enqueueNewsletterResendSync('run-1', 'admin-1') + + expect(mocks.getJobQueue).not.toHaveBeenCalled() + expect(mocks.queueEnqueue).not.toHaveBeenCalled() + expect(result).toEqual({ run: pushedRun, jobId: null }) + expect(pushNewsletterRunResponseSchema.shape.jobId.parse(result.jobId)).toBeNull() + }) + + it.each([ + ['completed', { id: 'trigger-run-existing', status: 'completed' }], + ['failed', { id: 'trigger-run-existing', status: 'failed', error: 'worker failed' }], + ['processing', { id: 'trigger-run-existing', status: 'processing' }], + ])( + 'does not enqueue when reconciliation of a %s job refreshes to pushed', + async (_providerState, providerJob) => { + const pushedRun = { ...run, status: 'pushed' } + mocks.claimAttempt + .mockResolvedValueOnce({ + attempt: 2, + jobId: 'trigger-run-existing', + run, + shouldEnqueue: false, + }) + .mockResolvedValueOnce({ + attempt: 2, + jobId: null, + run: pushedRun, + shouldEnqueue: false, + }) + mocks.queueGetJob.mockResolvedValue(providerJob) + + const result = await enqueueNewsletterResendSync('run-1', 'admin-1') + + expect(mocks.markFailed).toHaveBeenCalledWith('run-1', 2, expect.any(Error)) + expect(mocks.queueEnqueue).not.toHaveBeenCalled() + expect(mocks.setJob).not.toHaveBeenCalled() + expect(result).toEqual({ run: pushedRun, jobId: null }) + } + ) + it('re-enqueues when a stored Trigger.dev run no longer exists', async () => { mocks.claimAttempt .mockResolvedValueOnce({ @@ -342,6 +393,78 @@ describe('newsletter Resend queueing', () => { expect(mocks.markFailed).not.toHaveBeenCalled() }) + it('stops when the authoritative post-reset read is already pushed', async () => { + mocks.requireAttempt + .mockResolvedValueOnce(run) + .mockResolvedValueOnce({ ...run, status: 'pushed' }) + + await runNewsletterResendSync({ + runId: 'run-1', + attempt: 2, + requestedById: 'admin-1', + }) + + expect(mocks.createSegment).not.toHaveBeenCalled() + expect(mocks.setSegment).not.toHaveBeenCalled() + expect(mocks.ensureProperties).not.toHaveBeenCalled() + expect(mocks.markFailed).not.toHaveBeenCalled() + }) + + it('uses the segment from the authoritative post-reset read', async () => { + const staleRun = { + ...run, + resendSegmentId: null, + resendSegmentName: null, + } + const currentRun = { + ...run, + resendSegmentId: 'segment-current', + resendSegmentName: 'Current segment', + } + mocks.requireAttempt.mockResolvedValueOnce(staleRun).mockResolvedValueOnce(currentRun) + mocks.setSegment.mockResolvedValue(currentRun) + mocks.getExcludedEmails.mockResolvedValue(new Set()) + mocks.getPendingRecipients.mockResolvedValue([]) + mocks.countByStatus.mockResolvedValue({}) + + await runNewsletterResendSync({ + runId: 'run-1', + attempt: 2, + requestedById: 'admin-1', + }) + + expect(mocks.createSegment).not.toHaveBeenCalled() + expect(mocks.setSegment).toHaveBeenCalledWith('run-1', 2, 'segment-current', 'Current segment') + expect(mocks.markPushed).toHaveBeenCalledWith('run-1', 2, 'segment-current', 'Current segment') + }) + + it('stops when another worker pushes while a segment is being created', async () => { + const runWithoutSegment = { + ...run, + resendSegmentId: null, + resendSegmentName: null, + } + mocks.requireAttempt.mockResolvedValue(runWithoutSegment) + mocks.createSegment.mockResolvedValue({ id: 'segment-new', name: 'New segment' }) + mocks.setSegment.mockResolvedValue({ + ...run, + status: 'pushed', + resendSegmentId: 'segment-winner', + resendSegmentName: 'Winner segment', + }) + + await runNewsletterResendSync({ + runId: 'run-1', + attempt: 2, + requestedById: 'admin-1', + }) + + expect(mocks.setSegment).toHaveBeenCalledWith('run-1', 2, 'segment-new', 'New segment') + expect(mocks.ensureProperties).not.toHaveBeenCalled() + expect(mocks.markPushed).not.toHaveBeenCalled() + expect(mocks.markFailed).not.toHaveBeenCalled() + }) + it('does not fail the newsletter attempt when its database claim is aborted', async () => { const controller = new AbortController() controller.abort('claim lost') diff --git a/apps/sim/lib/newsletters/push-resend.ts b/apps/sim/lib/newsletters/push-resend.ts index 57bdeb9b73d..f9812a2d6a3 100644 --- a/apps/sim/lib/newsletters/push-resend.ts +++ b/apps/sim/lib/newsletters/push-resend.ts @@ -74,17 +74,32 @@ export async function runNewsletterResendSync( throw new Error('Newsletter run must be finalized before pushing to Resend') } await resetFailedNewsletterRecipients(runId, attempt) - await requireNewsletterRunAttempt(runId, attempt) + const currentRun = await requireNewsletterRunAttempt(runId, attempt) + if (currentRun.status === 'pushed') return + if (currentRun.status !== 'pushing' && currentRun.status !== 'failed') { + throw new Error('Newsletter run is not eligible to continue its Resend sync') + } signal?.throwIfAborted() - const segment = - run.resendSegmentId && run.resendSegmentName - ? { id: run.resendSegmentId, name: run.resendSegmentName } - : await createNewsletterSegment(segmentNameForRun(run.name), { signal }) + const segmentCandidate = + currentRun.resendSegmentId && currentRun.resendSegmentName + ? { id: currentRun.resendSegmentId, name: currentRun.resendSegmentName } + : await createNewsletterSegment(segmentNameForRun(currentRun.name), { signal }) signal?.throwIfAborted() - if (!run.resendSegmentId) { - await setNewsletterRunResendSegment(runId, attempt, segment.id, segment.name) + const segmentRun = await setNewsletterRunResendSegment( + runId, + attempt, + segmentCandidate.id, + segmentCandidate.name + ) + if (segmentRun.status === 'pushed') return + if (!segmentRun.resendSegmentId || !segmentRun.resendSegmentName) { + throw new Error('Newsletter Resend segment tracking is incomplete') + } + const segment = { + id: segmentRun.resendSegmentId, + name: segmentRun.resendSegmentName, } signal?.throwIfAborted() @@ -189,17 +204,21 @@ export async function runNewsletterResendSync( export async function enqueueNewsletterResendSync(runId: string, requestedById: string) { let claim = await claimNewsletterRunResendAttempt(runId) + if (claim.run.status === 'pushed') { + return { run: claim.run, jobId: claim.jobId } + } + const queue = await getJobQueue() const backendType = getAsyncBackendType() if (!claim.shouldEnqueue && claim.jobId) { - if (claim.run.status === 'pushed') { - return { run: claim.run, jobId: claim.jobId } - } const persistedJob = await queue.getJob(claim.jobId) if (persistedJob?.status === JOB_STATUS.COMPLETED) { const error = new Error('Newsletter sync job completed without finalizing the newsletter run') await markNewsletterRunPushFailed(runId, claim.attempt, error) claim = await claimNewsletterRunResendAttempt(runId) + if (claim.run.status === 'pushed') { + return { run: claim.run, jobId: claim.jobId } + } } else if (backendType !== 'database') { if ( persistedJob?.status === JOB_STATUS.PENDING || @@ -212,10 +231,16 @@ export async function enqueueNewsletterResendSync(runId: string, requestedById: ) await markNewsletterRunPushFailed(runId, claim.attempt, error) claim = await claimNewsletterRunResendAttempt(runId) + if (claim.run.status === 'pushed') { + return { run: claim.run, jobId: claim.jobId } + } } else if (persistedJob?.status === JOB_STATUS.FAILED) { const error = new Error(persistedJob.error ?? 'Newsletter sync job failed') await markNewsletterRunPushFailed(runId, claim.attempt, error) claim = await claimNewsletterRunResendAttempt(runId) + if (claim.run.status === 'pushed') { + return { run: claim.run, jobId: claim.jobId } + } } } @@ -254,5 +279,5 @@ export async function enqueueNewsletterResendSync(runId: string, requestedById: { cause: error } ) } - return { run: updatedRun, jobId } + return { run: updatedRun, jobId: updatedRun.resendSyncJobId ?? jobId } } diff --git a/apps/sim/lib/newsletters/resend.test.ts b/apps/sim/lib/newsletters/resend.test.ts index 43b6fcb4408..a266c137653 100644 --- a/apps/sim/lib/newsletters/resend.test.ts +++ b/apps/sim/lib/newsletters/resend.test.ts @@ -22,6 +22,8 @@ import { ensureNewsletterContactProperties, getResendExcludedEmails, getResendSuppressedEmails, + isNewsletterResendError, + NewsletterResendError, } from '@/lib/newsletters/resend' function jsonResponse(body: unknown, status = 200) { @@ -50,6 +52,11 @@ describe('newsletter Resend service', () => { ) }) + it('classifies only typed Resend service failures', () => { + expect(isNewsletterResendError(new NewsletterResendError('provider unavailable'))).toBe(true) + expect(isNewsletterResendError(new Error('Resend text from unrelated code'))).toBe(false) + }) + it('does not make a Resend request when already aborted', async () => { const controller = new AbortController() controller.abort('cancelled') @@ -60,6 +67,21 @@ describe('newsletter Resend service', () => { expect(fetchMock).not.toHaveBeenCalled() }) + it('preserves cancellation while reading a Resend error response', async () => { + const controller = new AbortController() + const body = new ReadableStream({ + pull(streamController) { + controller.abort('cancelled while reading') + streamController.error(new Error('body read failed')) + }, + }) + fetchMock.mockResolvedValueOnce(new Response(body, { status: 400 })) + + await expect(createNewsletterSegment('Segment 1', { signal: controller.signal })).rejects.toBe( + 'cancelled while reading' + ) + }) + it('does not retry after cancellation during backoff', async () => { const controller = new AbortController() fetchMock.mockResolvedValueOnce(jsonResponse({ message: 'retry' }, 429)) diff --git a/apps/sim/lib/newsletters/resend.ts b/apps/sim/lib/newsletters/resend.ts index 626e2c60fc5..0a688ace594 100644 --- a/apps/sim/lib/newsletters/resend.ts +++ b/apps/sim/lib/newsletters/resend.ts @@ -45,6 +45,17 @@ interface ResendRequestOptions { signal?: AbortSignal } +export class NewsletterResendError extends Error { + constructor(message: string, options?: ErrorOptions) { + super(message, options) + this.name = 'NewsletterResendError' + } +} + +export function isNewsletterResendError(error: unknown): error is NewsletterResendError { + return error instanceof NewsletterResendError +} + const resendSuppressionListSchema = z.object({ data: z.array(z.object({ id: z.string().min(1), email: z.string().min(1) })), has_more: z.boolean(), @@ -64,7 +75,7 @@ const resendContactListSchema = z.object({ function getResendApiKey(required = true): string | null { const key = env.RESEND_API_KEY?.trim() if (!key || key === 'placeholder') { - if (required) throw new Error('RESEND_API_KEY is not configured') + if (required) throw new NewsletterResendError('RESEND_API_KEY is not configured') return null } return key @@ -90,22 +101,38 @@ async function resendRequest(path: string, options: ResendRequestOptions = {} for (let attempt = 0; attempt < RESEND_MAX_ATTEMPTS; attempt++) { options.signal?.throwIfAborted() - const response = await fetch(`${RESEND_API_BASE}${path}`, { - method: options.method ?? 'GET', - headers: { - Authorization: `Bearer ${key}`, - 'Content-Type': 'application/json', - }, - body: options.body ? JSON.stringify(options.body) : undefined, - signal: options.signal, - }) + let response: Response + try { + response = await fetch(`${RESEND_API_BASE}${path}`, { + method: options.method ?? 'GET', + headers: { + Authorization: `Bearer ${key}`, + 'Content-Type': 'application/json', + }, + body: options.body ? JSON.stringify(options.body) : undefined, + signal: options.signal, + }) + } catch (error) { + options.signal?.throwIfAborted() + throw new NewsletterResendError(getErrorMessage(error, 'Resend request failed'), { + cause: error, + }) + } if (response.ok) { if (response.status === 204) return {} as T - return readResponseJsonWithLimit(response, { - maxBytes: options.maxResponseBytes ?? RESEND_MAX_RESPONSE_BYTES, - label: 'Resend API response', - }) + try { + return await readResponseJsonWithLimit(response, { + maxBytes: options.maxResponseBytes ?? RESEND_MAX_RESPONSE_BYTES, + label: 'Resend API response', + }) + } catch (error) { + options.signal?.throwIfAborted() + throw new NewsletterResendError( + getErrorMessage(error, 'Failed to read Resend API response'), + { cause: error } + ) + } } if (response.status === 429 || response.status >= 500) { @@ -115,10 +142,12 @@ async function resendRequest(path: string, options: ResendRequestOptions = {} continue } - throw new Error(await readError(response)) + const message = await readError(response) + options.signal?.throwIfAborted() + throw new NewsletterResendError(message) } - throw new Error('Resend request failed after retries') + throw new NewsletterResendError('Resend request failed after retries') } export async function getResendSuppressedEmails(options?: { @@ -139,7 +168,7 @@ export async function getResendSuppressedEmails(options?: { if (rawResponse === undefined) return emails const parsedResponse = resendSuppressionListSchema.safeParse(rawResponse) if (!parsedResponse.success) { - throw new Error('Resend suppression list response was malformed') + throw new NewsletterResendError('Resend suppression list response was malformed') } const response = parsedResponse.data @@ -150,11 +179,13 @@ export async function getResendSuppressedEmails(options?: { hasMore = response.has_more if (!hasMore) break after = response.data.at(-1)?.id ?? null - if (!after) throw new Error('Resend suppression pagination returned no cursor') + if (!after) { + throw new NewsletterResendError('Resend suppression pagination returned no cursor') + } } if (hasMore) { - throw new Error('Resend suppression list exceeded the pagination safety limit') + throw new NewsletterResendError('Resend suppression list exceeded the pagination safety limit') } return emails @@ -179,7 +210,7 @@ export async function getResendExcludedEmails(options?: { }) const parsedContacts = resendContactListSchema.safeParse(rawContacts) if (!parsedContacts.success) { - throw new Error('Resend contact list response was malformed') + throw new NewsletterResendError('Resend contact list response was malformed') } const contacts = parsedContacts.data @@ -192,11 +223,11 @@ export async function getResendExcludedEmails(options?: { hasMore = contacts.has_more if (!hasMore) break after = contacts.data.at(-1)?.id ?? null - if (!after) throw new Error('Resend contact pagination returned no cursor') + if (!after) throw new NewsletterResendError('Resend contact pagination returned no cursor') } if (hasMore) { - throw new Error('Resend contact list exceeded the pagination safety limit') + throw new NewsletterResendError('Resend contact list exceeded the pagination safety limit') } return excludedEmails @@ -236,11 +267,15 @@ export async function ensureNewsletterContactProperties(options?: { if (!hasMore) break after = response.data?.at(-1)?.id ?? null - if (!after) throw new Error('Resend contact property pagination returned no cursor') + if (!after) { + throw new NewsletterResendError('Resend contact property pagination returned no cursor') + } } if (hasMore) { - throw new Error('Resend contact property list exceeded the pagination safety limit') + throw new NewsletterResendError( + 'Resend contact property list exceeded the pagination safety limit' + ) } await Promise.all( diff --git a/apps/sim/lib/newsletters/runs.test.ts b/apps/sim/lib/newsletters/runs.test.ts index 318d4a5125a..1052754a6d6 100644 --- a/apps/sim/lib/newsletters/runs.test.ts +++ b/apps/sim/lib/newsletters/runs.test.ts @@ -14,9 +14,12 @@ vi.mock('@/lib/newsletters/resend', () => ({ })) import { + claimNewsletterRunResendAttempt, createNewsletterCsvExport, markNewsletterRunPushed, resetFailedNewsletterRecipients, + setNewsletterRunResendJob, + setNewsletterRunResendSegment, updateRecipientSyncStatus, } from '@/lib/newsletters/runs' @@ -108,6 +111,124 @@ describe('newsletter Resend state transitions', () => { expect(dbChainMockFns.select).toHaveBeenCalledOnce() expect(dbChainMockFns.update).toHaveBeenCalledOnce() }) + + it('never asks callers to enqueue an already pushed run', async () => { + queueTableRows(newsletterAudienceRuns, [ + { + ...finalizedRun, + status: 'pushed', + resendSyncAttempt: 2, + resendSyncJobId: null, + }, + ]) + dbChainMockFns.returning.mockResolvedValueOnce([]) + + const claim = await claimNewsletterRunResendAttempt('run-1') + + expect(claim.shouldEnqueue).toBe(false) + expect(claim.jobId).toBeNull() + }) + + it('preserves established pushed job tracking', async () => { + queueTableRows(newsletterAudienceRuns, [ + { + ...finalizedRun, + status: 'pushed', + resendSyncAttempt: 2, + resendSyncJobId: 'job-winner', + }, + ]) + + const result = await setNewsletterRunResendJob('run-1', 2, 'job-late') + + expect(dbChainMockFns.transaction).toHaveBeenCalledOnce() + expect(dbChainMockFns.for).toHaveBeenCalledWith('update') + expect(dbChainMockFns.update).not.toHaveBeenCalled() + expect(result.resendSyncJobId).toBe('job-winner') + }) + + it('fills missing pushed job tracking without changing the pushed state', async () => { + const pushedRun = { + ...finalizedRun, + status: 'pushed', + resendSyncAttempt: 2, + resendSyncJobId: null, + } + queueTableRows(newsletterAudienceRuns, [pushedRun]) + dbChainMockFns.returning.mockResolvedValueOnce([ + { ...pushedRun, resendSyncJobId: 'job-accepted' }, + ]) + + const result = await setNewsletterRunResendJob('run-1', 2, 'job-accepted') + + expect(dbChainMockFns.transaction).toHaveBeenCalledOnce() + expect(dbChainMockFns.for).toHaveBeenCalledWith('update') + expect(dbChainMockFns.update).toHaveBeenCalledOnce() + expect(result.status).toBe('pushed') + expect(result.resendSyncJobId).toBe('job-accepted') + }) + + it('does not overwrite a segment after the run is pushed', async () => { + const pushedRun = { + ...finalizedRun, + status: 'pushed', + resendSyncAttempt: 2, + resendSegmentId: 'segment-winner', + resendSegmentName: 'Winner segment', + } + queueTableRows(newsletterAudienceRuns, [pushedRun]) + + const result = await setNewsletterRunResendSegment('run-1', 2, 'segment-late', 'Late segment') + + expect(dbChainMockFns.transaction).toHaveBeenCalledOnce() + expect(dbChainMockFns.for).toHaveBeenCalledWith('update') + expect(dbChainMockFns.update).not.toHaveBeenCalled() + expect(result.resendSegmentId).toBe('segment-winner') + }) + + it('keeps the first segment persisted by same-attempt workers', async () => { + const runWithSegment = { + ...finalizedRun, + status: 'pushing', + resendSyncAttempt: 2, + resendSegmentId: 'segment-first', + resendSegmentName: 'First segment', + } + queueTableRows(newsletterAudienceRuns, [runWithSegment]) + + const result = await setNewsletterRunResendSegment( + 'run-1', + 2, + 'segment-second', + 'Second segment' + ) + + expect(dbChainMockFns.update).not.toHaveBeenCalled() + expect(result.resendSegmentId).toBe('segment-first') + }) + + it('locks the run while persisting the first segment', async () => { + const pushingRun = { + ...finalizedRun, + status: 'pushing', + resendSyncAttempt: 2, + } + queueTableRows(newsletterAudienceRuns, [pushingRun]) + dbChainMockFns.returning.mockResolvedValueOnce([ + { + ...pushingRun, + resendSegmentId: 'segment-first', + resendSegmentName: 'First segment', + }, + ]) + + const result = await setNewsletterRunResendSegment('run-1', 2, 'segment-first', 'First segment') + + expect(dbChainMockFns.transaction).toHaveBeenCalledOnce() + expect(dbChainMockFns.for).toHaveBeenCalledWith('update') + expect(dbChainMockFns.update).toHaveBeenCalledOnce() + expect(result.resendSegmentId).toBe('segment-first') + }) }) describe('newsletter CSV export', () => { diff --git a/apps/sim/lib/newsletters/runs.ts b/apps/sim/lib/newsletters/runs.ts index c9a74cb1e61..5e213fc94b2 100644 --- a/apps/sim/lib/newsletters/runs.ts +++ b/apps/sim/lib/newsletters/runs.ts @@ -489,7 +489,7 @@ export async function claimNewsletterRunResendAttempt( attempt: current.resendSyncAttempt, jobId: current.resendSyncJobId, run: serializeNewsletterRun(current), - shouldEnqueue: current.resendSyncJobId === null, + shouldEnqueue: false, } } if (current.status === 'pushing') { @@ -504,22 +504,43 @@ export async function claimNewsletterRunResendAttempt( } export async function setNewsletterRunResendJob(runId: string, attempt: number, jobId: string) { - const [updated] = await db - .update(newsletterAudienceRuns) - .set({ - resendSyncJobId: jobId, - updatedAt: new Date(), - }) - .where( - and( - eq(newsletterAudienceRuns.id, runId), - eq(newsletterAudienceRuns.resendSyncAttempt, attempt), - inArray(newsletterAudienceRuns.status, ['pushing', 'failed', 'pushed']) + return db.transaction(async (tx) => { + const [current] = await tx + .select() + .from(newsletterAudienceRuns) + .where(eq(newsletterAudienceRuns.id, runId)) + .for('update') + .limit(1) + if (!current || current.resendSyncAttempt !== attempt) { + throw new Error('Newsletter Resend enqueue attempt was superseded') + } + if (current.status === 'pushed' && current.resendSyncJobId) { + return serializeNewsletterRun(current) + } + if ( + current.status !== 'pushing' && + current.status !== 'failed' && + current.status !== 'pushed' + ) { + throw new Error('Newsletter run is not eligible to track a Resend sync job') + } + + const [updated] = await tx + .update(newsletterAudienceRuns) + .set({ + resendSyncJobId: jobId, + updatedAt: new Date(), + }) + .where( + and( + eq(newsletterAudienceRuns.id, runId), + eq(newsletterAudienceRuns.resendSyncAttempt, attempt) + ) ) - ) - .returning() - if (!updated) throw new Error('Newsletter Resend enqueue attempt was superseded') - return serializeNewsletterRun(updated) + .returning() + if (!updated) throw new Error('Newsletter Resend enqueue attempt was superseded') + return serializeNewsletterRun(updated) + }) } export async function markNewsletterRunPushFailed(runId: string, attempt: number, error: unknown) { @@ -608,19 +629,40 @@ export async function setNewsletterRunResendSegment( segmentId: string, segmentName: string ) { - await db - .update(newsletterAudienceRuns) - .set({ - resendSegmentId: segmentId, - resendSegmentName: segmentName, - updatedAt: new Date(), - }) - .where( - and( - eq(newsletterAudienceRuns.id, runId), - eq(newsletterAudienceRuns.resendSyncAttempt, attempt) + return db.transaction(async (tx) => { + const [current] = await tx + .select() + .from(newsletterAudienceRuns) + .where(eq(newsletterAudienceRuns.id, runId)) + .for('update') + .limit(1) + if (!current || current.resendSyncAttempt !== attempt) { + throw new Error('Newsletter Resend sync attempt was superseded') + } + if (current.status === 'pushed' || (current.resendSegmentId && current.resendSegmentName)) { + return serializeNewsletterRun(current) + } + if (current.status !== 'pushing' && current.status !== 'failed') { + throw new Error('Newsletter run is not eligible to persist a Resend segment') + } + + const [updated] = await tx + .update(newsletterAudienceRuns) + .set({ + resendSegmentId: segmentId, + resendSegmentName: segmentName, + updatedAt: new Date(), + }) + .where( + and( + eq(newsletterAudienceRuns.id, runId), + eq(newsletterAudienceRuns.resendSyncAttempt, attempt) + ) ) - ) + .returning() + if (!updated) throw new Error('Newsletter Resend sync attempt was superseded') + return serializeNewsletterRun(updated) + }) } const NEWSLETTER_CSV_PAGE_SIZE = 1000