diff --git a/apps/sim/app/api/cron/cleanup-stale-executions/route.ts b/apps/sim/app/api/cron/cleanup-stale-executions/route.ts index 99c395d644b..9f5bb1b868e 100644 --- a/apps/sim/app/api/cron/cleanup-stale-executions/route.ts +++ b/apps/sim/app/api/cron/cleanup-stale-executions/route.ts @@ -1,5 +1,5 @@ import { asyncJobs, db } from '@sim/db' -import { userTableDefinitions, workflowExecutionLogs } from '@sim/db/schema' +import { tableJobs, workflowExecutionLogs } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { and, eq, inArray, lt, sql } from 'drizzle-orm' @@ -8,12 +8,15 @@ import { verifyCronAuth } from '@/lib/auth/internal' import { JOB_RETENTION_HOURS, JOB_STATUS } from '@/lib/core/async-jobs' import { getMaxExecutionTimeout } from '@/lib/core/execution-limits' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { deleteFile } from '@/lib/uploads/core/storage-service' const logger = createLogger('CleanupStaleExecutions') const STALE_THRESHOLD_MS = getMaxExecutionTimeout() + 5 * 60 * 1000 const STALE_THRESHOLD_MINUTES = Math.ceil(STALE_THRESHOLD_MS / 60000) const MAX_INT32 = 2_147_483_647 +/** Terminal table-jobs older than this are pruned; only the latest job per table is ever read. */ +const TABLE_JOB_RETENTION_HOURS = 24 export const GET = withRouteHandler(async (request: NextRequest) => { try { @@ -110,33 +113,56 @@ export const GET = withRouteHandler(async (request: NextRequest) => { }) } - // Mark stale table imports as failed. Imports run detached on the web container and - // are lost if the pod is killed mid-load. `updatedAt` is bumped by progress updates, so - // an `importing` table with no recent update has stalled (not merely slow). Rows are - // left in place (no rollback); the user re-imports. + // Mark stale table jobs (import or delete) as failed. Jobs run detached on the web container + // and are lost if the pod is killed mid-run. `updated_at` is bumped by progress updates, so a + // `running` job with no recent update has stalled (not merely slow). Committed work is left in + // place (no rollback); the user retries. Also prune long-settled terminal jobs so the table + // doesn't grow unbounded (the latest job per table is what list/detail reads surface). let staleImportsMarkedFailed = 0 try { + const now = new Date() const staleImports = await db - .update(userTableDefinitions) + .update(tableJobs) .set({ - importStatus: 'failed', - importError: `Import terminated: no progress for more than ${STALE_THRESHOLD_MINUTES} minutes (worker timeout or crash)`, - updatedAt: new Date(), + status: 'failed', + error: `Job terminated: no progress for more than ${STALE_THRESHOLD_MINUTES} minutes (worker timeout or crash)`, + completedAt: now, + updatedAt: now, }) + .where(and(eq(tableJobs.status, 'running'), lt(tableJobs.updatedAt, staleThreshold))) + .returning({ id: tableJobs.id }) + + staleImportsMarkedFailed = staleImports.length + if (staleImportsMarkedFailed > 0) { + logger.info(`Marked ${staleImportsMarkedFailed} stale table jobs as failed`) + } + + const terminalRetention = new Date(Date.now() - TABLE_JOB_RETENTION_HOURS * 60 * 60 * 1000) + const pruned = await db + .delete(tableJobs) .where( and( - eq(userTableDefinitions.importStatus, 'importing'), - lt(userTableDefinitions.updatedAt, staleThreshold) + inArray(tableJobs.status, ['ready', 'failed', 'canceled']), + lt(tableJobs.updatedAt, terminalRetention) ) ) - .returning({ id: userTableDefinitions.id }) - - staleImportsMarkedFailed = staleImports.length - if (staleImportsMarkedFailed > 0) { - logger.info(`Marked ${staleImportsMarkedFailed} stale table imports as failed`) + .returning({ type: tableJobs.type, payload: tableJobs.payload }) + + // Pruned export jobs carry the generated file's storage key — delete the file with the job + // so the exports prefix doesn't accumulate. Best-effort: a miss just orphans one object. + for (const job of pruned) { + if (job.type !== 'export') continue + const resultKey = (job.payload as { resultKey?: string } | null)?.resultKey + if (!resultKey) continue + await deleteFile({ key: resultKey, context: 'workspace' }).catch((err) => { + logger.warn('Failed to delete pruned export file', { + resultKey, + error: toError(err).message, + }) + }) } } catch (error) { - logger.error('Failed to clean up stale table imports:', { + logger.error('Failed to clean up stale table jobs:', { error: toError(error).message, }) } diff --git a/apps/sim/app/api/table/[tableId]/cancel-runs/route.ts b/apps/sim/app/api/table/[tableId]/cancel-runs/route.ts index be89633d7e9..ce656d6be50 100644 --- a/apps/sim/app/api/table/[tableId]/cancel-runs/route.ts +++ b/apps/sim/app/api/table/[tableId]/cancel-runs/route.ts @@ -6,7 +6,7 @@ import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { cancelWorkflowGroupRuns } from '@/lib/table/workflow-columns' -import { accessError, checkAccess } from '@/app/api/table/utils' +import { accessError, checkAccess, tableFilterError } from '@/app/api/table/utils' const logger = createLogger('TableCancelRunsAPI') @@ -32,7 +32,7 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro const parsed = await parseRequest(cancelTableRunsContract, request, { params }) if (!parsed.success) return parsed.response const { tableId } = parsed.data.params - const { workspaceId, scope, rowId } = parsed.data.body + const { workspaceId, scope, rowId, filter, excludeRowIds } = parsed.data.body const result = await checkAccess(tableId, authResult.userId, 'write') if (!result.ok) return accessError(result, requestId, tableId) @@ -42,7 +42,13 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) } - const cancelled = await cancelWorkflowGroupRuns(tableId, scope === 'row' ? rowId : undefined) + const filterError = tableFilterError(filter, table.schema.columns) + if (filterError) return filterError + + const cancelled = await cancelWorkflowGroupRuns(tableId, scope === 'row' ? rowId : undefined, { + filter, + excludeRowIds, + }) logger.info( `[${requestId}] cancel-runs: tableId=${tableId} scope=${scope}${ rowId ? ` rowId=${rowId}` : '' diff --git a/apps/sim/app/api/table/[tableId]/columns/route.ts b/apps/sim/app/api/table/[tableId]/columns/route.ts index 6b87c84f644..7eecb5ee466 100644 --- a/apps/sim/app/api/table/[tableId]/columns/route.ts +++ b/apps/sim/app/api/table/[tableId]/columns/route.ts @@ -17,7 +17,7 @@ import { updateColumnConstraints, updateColumnType, } from '@/lib/table' -import { accessError, checkAccess, normalizeColumn } from '@/app/api/table/utils' +import { accessError, checkAccess, normalizeColumn, rootErrorMessage } from '@/app/api/table/utils' const logger = createLogger('TableColumnsAPI') @@ -63,13 +63,17 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Colum return validationErrorResponse(error, 'Invalid request data') } - if (error instanceof Error) { - if (error.message.includes('already exists') || error.message.includes('maximum column')) { - return NextResponse.json({ error: error.message }, { status: 400 }) - } - if (error.message === 'Table not found') { - return NextResponse.json({ error: error.message }, { status: 404 }) - } + const msg = rootErrorMessage(error) + if ( + msg.includes('already exists') || + msg.includes('maximum column') || + msg.includes('Invalid column') || + msg.includes('exceeds maximum') + ) { + return NextResponse.json({ error: msg }, { status: 400 }) + } + if (msg === 'Table not found') { + return NextResponse.json({ error: msg }, { status: 404 }) } logger.error(`[${requestId}] Error adding column to table ${tableId}:`, error) @@ -146,22 +150,21 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Colu return validationErrorResponse(error, 'Invalid request data') } - if (error instanceof Error) { - const msg = error.message - if (msg.includes('not found') || msg.includes('Table not found')) { - return NextResponse.json({ error: msg }, { status: 404 }) - } - if ( - msg.includes('already exists') || - msg.includes('Cannot delete the last column') || - msg.includes('Cannot set column') || - msg.includes('Invalid column') || - msg.includes('exceeds maximum') || - msg.includes('incompatible') || - msg.includes('duplicate') - ) { - return NextResponse.json({ error: msg }, { status: 400 }) - } + const msg = rootErrorMessage(error) + if (msg.includes('not found') || msg.includes('Table not found')) { + return NextResponse.json({ error: msg }, { status: 404 }) + } + if ( + msg.includes('already exists') || + msg.includes('Cannot delete the last column') || + msg.includes('Cannot set column') || + msg.includes('Cannot set unique column') || + msg.includes('Invalid column') || + msg.includes('exceeds maximum') || + msg.includes('incompatible') || + msg.includes('duplicate') + ) { + return NextResponse.json({ error: msg }, { status: 400 }) } logger.error(`[${requestId}] Error updating column in table ${tableId}:`, error) @@ -211,13 +214,12 @@ export const DELETE = withRouteHandler( return validationErrorResponse(error, 'Invalid request data') } - if (error instanceof Error) { - if (error.message.includes('not found') || error.message === 'Table not found') { - return NextResponse.json({ error: error.message }, { status: 404 }) - } - if (error.message.includes('Cannot delete') || error.message.includes('last column')) { - return NextResponse.json({ error: error.message }, { status: 400 }) - } + const msg = rootErrorMessage(error) + if (msg.includes('not found') || msg === 'Table not found') { + return NextResponse.json({ error: msg }, { status: 404 }) + } + if (msg.includes('Cannot delete') || msg.includes('last column')) { + return NextResponse.json({ error: msg }, { status: 400 }) } logger.error(`[${requestId}] Error deleting column from table ${tableId}:`, error) diff --git a/apps/sim/app/api/table/[tableId]/columns/run/route.ts b/apps/sim/app/api/table/[tableId]/columns/run/route.ts index 341f58662b0..00856ae4a1a 100644 --- a/apps/sim/app/api/table/[tableId]/columns/run/route.ts +++ b/apps/sim/app/api/table/[tableId]/columns/run/route.ts @@ -6,7 +6,7 @@ import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { runWorkflowColumn } from '@/lib/table/workflow-columns' -import { accessError, checkAccess } from '@/app/api/table/utils' +import { accessError, checkAccess, tableFilterError } from '@/app/api/table/utils' const logger = createLogger('TableRunColumnAPI') @@ -25,16 +25,23 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro const parsed = await parseRequest(runColumnContract, request, { params }) if (!parsed.success) return parsed.response const { tableId } = parsed.data.params - const { workspaceId, groupIds, runMode, rowIds, limit } = parsed.data.body + const { workspaceId, groupIds, runMode, rowIds, filter, excludeRowIds, limit } = + parsed.data.body const access = await checkAccess(tableId, auth.userId, 'write') if (!access.ok) return accessError(access, requestId, tableId) + // Validate the filter up front (the dispatcher reuses it) so a bad field fails fast. + const filterError = tableFilterError(filter, access.table.schema.columns) + if (filterError) return filterError + const { dispatchId } = await runWorkflowColumn({ tableId, workspaceId, groupIds, mode: runMode, rowIds, + filter, + excludeRowIds, limit, requestId, triggeredByUserId: auth.userId, diff --git a/apps/sim/app/api/table/[tableId]/delete-async/route.test.ts b/apps/sim/app/api/table/[tableId]/delete-async/route.test.ts new file mode 100644 index 00000000000..9565725c8a6 --- /dev/null +++ b/apps/sim/app/api/table/[tableId]/delete-async/route.test.ts @@ -0,0 +1,213 @@ +/** + * @vitest-environment node + */ +import { hybridAuthMockFns } from '@sim/testing' +import { NextRequest, NextResponse } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { TableDefinition } from '@/lib/table' + +const { + mockCheckAccess, + mockMarkTableJobRunning, + mockReleaseJobClaim, + mockRunTableDelete, + mockTableFilterError, + mockTasksTrigger, + flags, +} = vi.hoisted(() => ({ + mockCheckAccess: vi.fn(), + mockMarkTableJobRunning: vi.fn(), + mockReleaseJobClaim: vi.fn(), + mockRunTableDelete: vi.fn(), + mockTableFilterError: vi.fn(), + mockTasksTrigger: vi.fn(), + flags: { triggerDev: false }, +})) + +vi.mock('@sim/utils/id', () => ({ + generateId: vi.fn().mockReturnValue('job-id-xyz'), + generateShortId: vi.fn().mockReturnValue('short-id'), +})) +vi.mock('@/lib/table/service', () => ({ + markTableJobRunning: mockMarkTableJobRunning, + releaseJobClaim: mockReleaseJobClaim, +})) +vi.mock('@/lib/table/delete-runner', () => ({ runTableDelete: mockRunTableDelete })) +vi.mock('@/lib/core/config/feature-flags', () => ({ + get isTriggerDevEnabled() { + return flags.triggerDev + }, +})) +vi.mock('@/background/table-delete', () => ({ tableDeleteTask: { id: 'table-delete' } })) +vi.mock('@trigger.dev/sdk', () => ({ + tasks: { trigger: mockTasksTrigger }, + task: (config: unknown) => config, +})) +vi.mock('@/lib/core/utils/background', () => ({ + runDetached: (_label: string, work: () => Promise) => { + void work() + }, +})) +vi.mock('@/app/api/table/utils', async () => { + const { NextResponse } = await import('next/server') + return { + checkAccess: mockCheckAccess, + accessError: (result: { status: number }) => + NextResponse.json({ error: 'denied' }, { status: result.status }), + tableFilterError: mockTableFilterError, + } +}) + +import { POST } from '@/app/api/table/[tableId]/delete-async/route' + +function buildTable(overrides: Partial = {}): TableDefinition { + return { + id: 'tbl_1', + name: 'People', + description: null, + schema: { columns: [{ name: 'status', type: 'string' }] }, + metadata: null, + rowCount: 1000, + maxRows: 1_000_000, + workspaceId: 'workspace-1', + createdBy: 'user-1', + archivedAt: null, + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, + } +} + +function makeRequest(body: unknown, tableId = 'tbl_1') { + const req = new NextRequest(`http://localhost:3000/api/table/${tableId}/delete-async`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }) + return POST(req, { params: Promise.resolve({ tableId }) }) +} + +const validBody = { + workspaceId: 'workspace-1', + filter: { status: 'archived' }, + excludeRowIds: ['row_keep'], +} + +describe('POST /api/table/[tableId]/delete-async', () => { + beforeEach(() => { + vi.clearAllMocks() + hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({ + success: true, + userId: 'user-1', + authType: 'session', + }) + mockCheckAccess.mockResolvedValue({ ok: true, table: buildTable() }) + mockMarkTableJobRunning.mockResolvedValue(true) + mockRunTableDelete.mockResolvedValue(undefined) + mockTableFilterError.mockReturnValue(null) + mockTasksTrigger.mockResolvedValue({ id: 'run_1' }) + flags.triggerDev = false + }) + + it('claims the job slot and kicks off the delete worker with filter + exclusions', async () => { + const response = await makeRequest(validBody) + const data = await response.json() + + expect(response.status).toBe(200) + expect(data.data).toEqual({ tableId: 'tbl_1', jobId: 'job-id-xyz' }) + expect(mockMarkTableJobRunning).toHaveBeenCalledWith('tbl_1', 'job-id-xyz', 'delete', { + filter: { status: 'archived' }, + excludeRowIds: ['row_keep'], + cutoff: expect.any(String), + }) + expect(mockRunTableDelete).toHaveBeenCalledWith( + expect.objectContaining({ + jobId: 'job-id-xyz', + tableId: 'tbl_1', + workspaceId: 'workspace-1', + filter: { status: 'archived' }, + excludeRowIds: ['row_keep'], + cutoff: expect.any(Date), + }) + ) + }) + + it('allows a whole-table delete with no filter', async () => { + const response = await makeRequest({ workspaceId: 'workspace-1' }) + expect(response.status).toBe(200) + expect(mockRunTableDelete).toHaveBeenCalledWith( + expect.objectContaining({ filter: undefined, cutoff: expect.any(Date) }) + ) + }) + + it('returns 409 when a job is already in progress (claim lost)', async () => { + mockMarkTableJobRunning.mockResolvedValue(false) + const response = await makeRequest(validBody) + expect(response.status).toBe(409) + expect(mockRunTableDelete).not.toHaveBeenCalled() + }) + + it('returns 400 on an invalid filter without claiming the slot', async () => { + mockTableFilterError.mockReturnValue(NextResponse.json({ error: 'bad field' }, { status: 400 })) + const response = await makeRequest(validBody) + expect(response.status).toBe(400) + expect(mockMarkTableJobRunning).not.toHaveBeenCalled() + expect(mockRunTableDelete).not.toHaveBeenCalled() + }) + + it('returns 401 when unauthenticated', async () => { + hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({ success: false }) + const response = await makeRequest(validBody) + expect(response.status).toBe(401) + expect(mockMarkTableJobRunning).not.toHaveBeenCalled() + }) + + it('returns the access error status when access is denied', async () => { + mockCheckAccess.mockResolvedValue({ ok: false, status: 403 }) + const response = await makeRequest(validBody) + expect(response.status).toBe(403) + expect(mockRunTableDelete).not.toHaveBeenCalled() + }) + + it('returns 400 when the table is archived', async () => { + mockCheckAccess.mockResolvedValue({ ok: true, table: buildTable({ archivedAt: new Date() }) }) + const response = await makeRequest(validBody) + expect(response.status).toBe(400) + expect(mockRunTableDelete).not.toHaveBeenCalled() + }) + + it('returns 400 on workspace mismatch', async () => { + const response = await makeRequest({ ...validBody, workspaceId: 'other-ws' }) + expect(response.status).toBe(400) + }) + + it('routes through trigger.dev (ISO cutoff, tagged) when the flag is on', async () => { + flags.triggerDev = true + const response = await makeRequest(validBody) + + expect(response.status).toBe(200) + expect(mockRunTableDelete).not.toHaveBeenCalled() + expect(mockTasksTrigger).toHaveBeenCalledWith( + 'table-delete', + expect.objectContaining({ + jobId: 'job-id-xyz', + tableId: 'tbl_1', + filter: { status: 'archived' }, + excludeRowIds: ['row_keep'], + cutoff: expect.any(String), + }), + { tags: ['tableId:tbl_1', 'jobId:job-id-xyz'] } + ) + }) + + it('releases the job claim when the trigger.dev dispatch fails (no ghost running job)', async () => { + flags.triggerDev = true + mockTasksTrigger.mockRejectedValueOnce(new Error('trigger.dev unreachable')) + + const response = await makeRequest(validBody) + + expect(response.status).toBe(500) + expect(mockReleaseJobClaim).toHaveBeenCalledWith('tbl_1', 'job-id-xyz') + expect(mockRunTableDelete).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/table/[tableId]/delete-async/route.ts b/apps/sim/app/api/table/[tableId]/delete-async/route.ts new file mode 100644 index 00000000000..e8a2d49b862 --- /dev/null +++ b/apps/sim/app/api/table/[tableId]/delete-async/route.ts @@ -0,0 +1,118 @@ +import { createLogger } from '@sim/logger' +import { generateId } from '@sim/utils/id' +import { type NextRequest, NextResponse } from 'next/server' +import { deleteTableRowsAsyncContract } from '@/lib/api/contracts/tables' +import { parseRequest } from '@/lib/api/server' +import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' +import { isTriggerDevEnabled } from '@/lib/core/config/feature-flags' +import { runDetached } from '@/lib/core/utils/background' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { runTableDelete } from '@/lib/table/delete-runner' +import { markTableJobRunning } from '@/lib/table/service' +import type { TableDeleteJobPayload } from '@/lib/table/types' +import { accessError, checkAccess, tableFilterError } from '@/app/api/table/utils' + +const logger = createLogger('TableDeleteAsync') + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +interface RouteParams { + params: Promise<{ tableId: string }> +} + +/** + * POST /api/table/[tableId]/delete-async + * + * Kicks off a background "select all" delete: the client sends the active filter (and an optional + * exclusion set) instead of every row id. Claims the table's single job slot (mutually exclusive + * with imports), captures a `created_at` cutoff so rows inserted while the job runs survive, then + * runs the paginated delete worker detached. + */ +export const POST = withRouteHandler(async (request: NextRequest, { params }: RouteParams) => { + const requestId = generateRequestId() + + const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) + if (!authResult.success || !authResult.userId) { + return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) + } + const userId = authResult.userId + + const parsed = await parseRequest(deleteTableRowsAsyncContract, request, { params }) + if (!parsed.success) return parsed.response + const { tableId } = parsed.data.params + const { workspaceId, filter, excludeRowIds } = parsed.data.body + + const access = await checkAccess(tableId, userId, 'write') + if (!access.ok) return accessError(access, requestId, tableId) + const { table } = access + + if (table.workspaceId !== workspaceId) { + return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) + } + if (table.archivedAt) { + return NextResponse.json({ error: 'Cannot delete from an archived table' }, { status: 400 }) + } + + // Validate the filter up front so the caller gets immediate feedback (the worker reuses it). + const filterError = tableFilterError(filter, table.schema.columns) + if (filterError) return filterError + + // Rows inserted after this instant are spared (created_at <= cutoff in the worker). + const cutoff = new Date() + + // Atomically claim the job slot — one background job per table, so this also blocks while an + // import is in flight (and vice versa). The scope is persisted to the job's payload so read + // paths can mask the doomed rows while the job runs (see `pendingDeleteMask`). + const jobId = generateId() + const payload: TableDeleteJobPayload = { filter, excludeRowIds, cutoff: cutoff.toISOString() } + const claimed = await markTableJobRunning(tableId, jobId, 'delete', payload) + if (!claimed) { + return NextResponse.json( + { error: 'A job is already in progress for this table' }, + { status: 409 } + ) + } + + if (isTriggerDevEnabled) { + // Trigger.dev runs the delete outside the web container (survives deploys) and retries — + // safe: the keyset + cutoff walk just deletes whatever remains. + try { + const [{ tableDeleteTask }, { tasks }] = await Promise.all([ + import('@/background/table-delete'), + import('@trigger.dev/sdk'), + ]) + await tasks.trigger( + 'table-delete', + { jobId, tableId, workspaceId, filter, excludeRowIds, cutoff: cutoff.toISOString() }, + { tags: [`tableId:${tableId}`, `jobId:${jobId}`] } + ) + } catch (error) { + // A failed dispatch must not leave a ghost `running` job holding the + // table's one-write-job slot until the stale-job janitor fires. + const { releaseJobClaim } = await import('@/lib/table/service') + await releaseJobClaim(tableId, jobId).catch(() => {}) + throw error + } + } else { + runDetached('table-delete', () => + runTableDelete({ + jobId, + tableId, + workspaceId, + filter, + excludeRowIds, + cutoff, + }) + ) + } + + logger.info(`[${requestId}] Async row delete started`, { + tableId, + jobId, + hasFilter: Boolean(filter), + excluded: excludeRowIds?.length ?? 0, + }) + return NextResponse.json({ success: true, data: { tableId, jobId } }) +}) diff --git a/apps/sim/app/api/table/[tableId]/export-async/route.test.ts b/apps/sim/app/api/table/[tableId]/export-async/route.test.ts new file mode 100644 index 00000000000..177e02abf37 --- /dev/null +++ b/apps/sim/app/api/table/[tableId]/export-async/route.test.ts @@ -0,0 +1,128 @@ +/** + * @vitest-environment node + */ +import { hybridAuthMockFns } from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { TableDefinition } from '@/lib/table' + +const { mockCheckAccess, mockMarkTableJobRunning, mockRunTableExport } = vi.hoisted(() => ({ + mockCheckAccess: vi.fn(), + mockMarkTableJobRunning: vi.fn(), + mockRunTableExport: vi.fn(), +})) + +vi.mock('@sim/utils/id', () => ({ + generateId: vi.fn().mockReturnValue('job-id-xyz'), + generateShortId: vi.fn().mockReturnValue('short-id'), +})) +vi.mock('@/lib/table/service', () => ({ markTableJobRunning: mockMarkTableJobRunning })) +vi.mock('@/lib/table/export-runner', () => ({ runTableExport: mockRunTableExport })) +vi.mock('@/lib/core/utils/background', () => ({ + runDetached: (_label: string, work: () => Promise) => { + void work() + }, +})) +vi.mock('@/app/api/table/utils', async () => { + const { NextResponse } = await import('next/server') + return { + checkAccess: mockCheckAccess, + accessError: (result: { status: number }) => + NextResponse.json({ error: 'denied' }, { status: result.status }), + } +}) + +import { POST } from '@/app/api/table/[tableId]/export-async/route' + +function buildTable(overrides: Partial = {}): TableDefinition { + return { + id: 'tbl_1', + name: 'People', + description: null, + schema: { columns: [{ name: 'name', type: 'string' }] }, + metadata: null, + rowCount: 50000, + maxRows: 1_000_000, + workspaceId: 'workspace-1', + createdBy: 'user-1', + archivedAt: null, + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, + } +} + +function makeRequest(body: unknown, tableId = 'tbl_1') { + const req = new NextRequest(`http://localhost:3000/api/table/${tableId}/export-async`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }) + return POST(req, { params: Promise.resolve({ tableId }) }) +} + +const validBody = { workspaceId: 'workspace-1', format: 'csv' } + +describe('POST /api/table/[tableId]/export-async', () => { + beforeEach(() => { + vi.clearAllMocks() + hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({ + success: true, + userId: 'user-1', + authType: 'session', + }) + mockCheckAccess.mockResolvedValue({ ok: true, table: buildTable() }) + mockMarkTableJobRunning.mockResolvedValue(true) + mockRunTableExport.mockResolvedValue(undefined) + }) + + it('claims an export job and kicks off the worker', async () => { + const response = await makeRequest(validBody) + const data = await response.json() + + expect(response.status).toBe(200) + expect(data.data).toEqual({ tableId: 'tbl_1', jobId: 'job-id-xyz' }) + expect(mockMarkTableJobRunning).toHaveBeenCalledWith('tbl_1', 'job-id-xyz', 'export', { + format: 'csv', + }) + expect(mockRunTableExport).toHaveBeenCalledWith({ + jobId: 'job-id-xyz', + tableId: 'tbl_1', + workspaceId: 'workspace-1', + format: 'csv', + }) + }) + + it('defaults the format to csv', async () => { + const response = await makeRequest({ workspaceId: 'workspace-1' }) + expect(response.status).toBe(200) + expect(mockRunTableExport).toHaveBeenCalledWith(expect.objectContaining({ format: 'csv' })) + }) + + it('returns 409 when the claim fails', async () => { + mockMarkTableJobRunning.mockResolvedValue(false) + const response = await makeRequest(validBody) + expect(response.status).toBe(409) + expect(mockRunTableExport).not.toHaveBeenCalled() + }) + + it('returns 401 when unauthenticated', async () => { + hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({ success: false }) + const response = await makeRequest(validBody) + expect(response.status).toBe(401) + expect(mockMarkTableJobRunning).not.toHaveBeenCalled() + }) + + it('returns the access error status when access is denied', async () => { + mockCheckAccess.mockResolvedValue({ ok: false, status: 403 }) + const response = await makeRequest(validBody) + expect(response.status).toBe(403) + expect(mockRunTableExport).not.toHaveBeenCalled() + }) + + it('returns 400 on workspace mismatch', async () => { + const response = await makeRequest({ ...validBody, workspaceId: 'other-ws' }) + expect(response.status).toBe(400) + expect(mockMarkTableJobRunning).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/table/[tableId]/export-async/route.ts b/apps/sim/app/api/table/[tableId]/export-async/route.ts new file mode 100644 index 00000000000..4213901bd1c --- /dev/null +++ b/apps/sim/app/api/table/[tableId]/export-async/route.ts @@ -0,0 +1,84 @@ +import { createLogger } from '@sim/logger' +import { generateId } from '@sim/utils/id' +import { type NextRequest, NextResponse } from 'next/server' +import { exportTableAsyncContract } from '@/lib/api/contracts/tables' +import { parseRequest } from '@/lib/api/server' +import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' +import { isTriggerDevEnabled } from '@/lib/core/config/feature-flags' +import { runDetached } from '@/lib/core/utils/background' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { runTableExport, type TableExportPayload } from '@/lib/table/export-runner' +import { markTableJobRunning } from '@/lib/table/service' +import type { TableExportJobPayload } from '@/lib/table/types' +import { accessError, checkAccess } from '@/app/api/table/utils' + +const logger = createLogger('TableExportAsync') + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +interface RouteParams { + params: Promise<{ tableId: string }> +} + +/** + * POST /api/table/[tableId]/export-async + * + * Kicks off a background export for large tables (small ones stream synchronously via `/export`). + * Export jobs are read-only, so they bypass the one-running-job-per-table gate (the partial-unique + * index excludes `type = 'export'`) — an export can run alongside an import or delete, and the + * delete-mask keeps a mid-delete export consistent with the delete's outcome. + */ +export const POST = withRouteHandler(async (request: NextRequest, { params }: RouteParams) => { + const requestId = generateRequestId() + + const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) + if (!authResult.success || !authResult.userId) { + return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) + } + + const parsed = await parseRequest(exportTableAsyncContract, request, { params }) + if (!parsed.success) return parsed.response + const { tableId } = parsed.data.params + const { workspaceId, format } = parsed.data.body + + const access = await checkAccess(tableId, authResult.userId, 'read') + if (!access.ok) return accessError(access, requestId, tableId) + if (access.table.workspaceId !== workspaceId) { + return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) + } + + const jobId = generateId() + const jobPayload: TableExportJobPayload = { format } + const claimed = await markTableJobRunning(tableId, jobId, 'export', jobPayload) + if (!claimed) { + // Only possible against another running *export*-typed insert race losing on the pkey, or a + // missing table — the active-job index excludes exports. + return NextResponse.json({ error: 'Failed to start export' }, { status: 409 }) + } + + const payload: TableExportPayload = { jobId, tableId, workspaceId, format } + if (isTriggerDevEnabled) { + try { + const [{ tableExportTask }, { tasks }] = await Promise.all([ + import('@/background/table-export'), + import('@trigger.dev/sdk'), + ]) + await tasks.trigger('table-export', payload, { + tags: [`tableId:${tableId}`, `jobId:${jobId}`], + }) + } catch (error) { + // A failed dispatch must not leave a ghost `running` job holding the + // table's one-write-job slot until the stale-job janitor fires. + const { releaseJobClaim } = await import('@/lib/table/service') + await releaseJobClaim(tableId, jobId).catch(() => {}) + throw error + } + } else { + runDetached('table-export', () => runTableExport(payload)) + } + + logger.info(`[${requestId}] Async export started`, { tableId, jobId, format }) + return NextResponse.json({ success: true, data: { tableId, jobId } }) +}) diff --git a/apps/sim/app/api/table/[tableId]/export/download/route.test.ts b/apps/sim/app/api/table/[tableId]/export/download/route.test.ts new file mode 100644 index 00000000000..c3458093e68 --- /dev/null +++ b/apps/sim/app/api/table/[tableId]/export/download/route.test.ts @@ -0,0 +1,124 @@ +/** + * @vitest-environment node + */ +import { hybridAuthMockFns } from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { TableDefinition } from '@/lib/table' + +const { mockCheckAccess, mockGetTableJob, mockGeneratePresignedDownloadUrl } = vi.hoisted(() => ({ + mockCheckAccess: vi.fn(), + mockGetTableJob: vi.fn(), + mockGeneratePresignedDownloadUrl: vi.fn(), +})) + +vi.mock('@/lib/table/service', () => ({ getTableJob: mockGetTableJob })) +vi.mock('@/lib/uploads/core/storage-service', () => ({ + generatePresignedDownloadUrl: mockGeneratePresignedDownloadUrl, +})) +vi.mock('@/app/api/table/utils', async () => { + const { NextResponse } = await import('next/server') + return { + checkAccess: mockCheckAccess, + accessError: (result: { status: number }) => + NextResponse.json({ error: 'denied' }, { status: result.status }), + } +}) + +import { GET } from '@/app/api/table/[tableId]/export/download/route' + +function buildTable(overrides: Partial = {}): TableDefinition { + return { + id: 'tbl_1', + name: 'People', + description: null, + schema: { columns: [] }, + metadata: null, + rowCount: 0, + maxRows: 1_000_000, + workspaceId: 'workspace-1', + createdBy: 'user-1', + archivedAt: null, + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, + } +} + +function makeRequest(query: Record, tableId = 'tbl_1') { + const qs = new URLSearchParams(query).toString() + const req = new NextRequest(`http://localhost:3000/api/table/${tableId}/export/download?${qs}`) + return GET(req, { params: Promise.resolve({ tableId }) }) +} + +const validQuery = { workspaceId: 'workspace-1', jobId: 'job_1' } + +describe('GET /api/table/[tableId]/export/download', () => { + beforeEach(() => { + vi.clearAllMocks() + hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({ + success: true, + userId: 'user-1', + authType: 'session', + }) + mockCheckAccess.mockResolvedValue({ ok: true, table: buildTable() }) + mockGetTableJob.mockResolvedValue({ + id: 'job_1', + type: 'export', + status: 'ready', + payload: { format: 'csv', resultKey: 'workspace/workspace-1/exports/tbl_1/job_1/people.csv' }, + }) + mockGeneratePresignedDownloadUrl.mockResolvedValue('https://storage.example/signed-url') + }) + + it('resolves a ready export to a presigned URL', async () => { + const response = await makeRequest(validQuery) + const data = await response.json() + + expect(response.status).toBe(200) + expect(data.data).toEqual({ url: 'https://storage.example/signed-url', fileName: 'people.csv' }) + expect(mockGeneratePresignedDownloadUrl).toHaveBeenCalledWith( + 'workspace/workspace-1/exports/tbl_1/job_1/people.csv', + 'workspace' + ) + }) + + it('404s when the job is missing or not an export', async () => { + mockGetTableJob.mockResolvedValue({ id: 'job_1', type: 'delete', status: 'ready', payload: {} }) + const response = await makeRequest(validQuery) + expect(response.status).toBe(404) + }) + + it('409s when the export is not ready yet', async () => { + mockGetTableJob.mockResolvedValue({ + id: 'job_1', + type: 'export', + status: 'running', + payload: { format: 'csv' }, + }) + const response = await makeRequest(validQuery) + expect(response.status).toBe(409) + }) + + it('410s when the result file is gone from the payload', async () => { + mockGetTableJob.mockResolvedValue({ + id: 'job_1', + type: 'export', + status: 'ready', + payload: { format: 'csv' }, + }) + const response = await makeRequest(validQuery) + expect(response.status).toBe(410) + }) + + it('returns 401 when unauthenticated', async () => { + hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({ success: false }) + const response = await makeRequest(validQuery) + expect(response.status).toBe(401) + }) + + it('returns 400 on workspace mismatch', async () => { + const response = await makeRequest({ ...validQuery, workspaceId: 'other-ws' }) + expect(response.status).toBe(400) + }) +}) diff --git a/apps/sim/app/api/table/[tableId]/export/download/route.ts b/apps/sim/app/api/table/[tableId]/export/download/route.ts new file mode 100644 index 00000000000..577c2747b8c --- /dev/null +++ b/apps/sim/app/api/table/[tableId]/export/download/route.ts @@ -0,0 +1,64 @@ +import { createLogger } from '@sim/logger' +import { type NextRequest, NextResponse } from 'next/server' +import { exportDownloadContract } from '@/lib/api/contracts/tables' +import { parseRequest } from '@/lib/api/server' +import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { getTableJob } from '@/lib/table/service' +import type { TableExportJobPayload } from '@/lib/table/types' +import { generatePresignedDownloadUrl } from '@/lib/uploads/core/storage-service' +import { accessError, checkAccess } from '@/app/api/table/utils' + +const logger = createLogger('TableExportDownload') + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +interface RouteParams { + params: Promise<{ tableId: string }> +} + +/** + * GET /api/table/[tableId]/export/download?jobId=… + * + * Resolves a completed export job to a short-lived presigned URL for the generated file. The job + * must belong to the table, be an export, and be `ready` — the worker stamps `resultKey` onto the + * job payload when the upload lands. + */ +export const GET = withRouteHandler(async (request: NextRequest, { params }: RouteParams) => { + const requestId = generateRequestId() + + const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) + if (!authResult.success || !authResult.userId) { + return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) + } + + const parsed = await parseRequest(exportDownloadContract, request, { params }) + if (!parsed.success) return parsed.response + const { tableId } = parsed.data.params + const { workspaceId, jobId } = parsed.data.query + + const access = await checkAccess(tableId, authResult.userId, 'read') + if (!access.ok) return accessError(access, requestId, tableId) + if (access.table.workspaceId !== workspaceId) { + return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) + } + + const job = await getTableJob(tableId, jobId) + if (!job || job.type !== 'export') { + return NextResponse.json({ error: 'Export job not found' }, { status: 404 }) + } + if (job.status !== 'ready') { + return NextResponse.json({ error: 'Export is not ready' }, { status: 409 }) + } + const payload = job.payload as TableExportJobPayload | null + if (!payload?.resultKey) { + return NextResponse.json({ error: 'Export file is no longer available' }, { status: 410 }) + } + + const url = await generatePresignedDownloadUrl(payload.resultKey, 'workspace') + const fileName = payload.resultKey.split('/').pop() ?? `export.${payload.format}` + logger.info(`[${requestId}] Export download URL issued`, { tableId, jobId }) + return NextResponse.json({ success: true, data: { url, fileName } }) +}) diff --git a/apps/sim/app/api/table/[tableId]/import-async/route.test.ts b/apps/sim/app/api/table/[tableId]/import-async/route.test.ts index 18fa93aca80..7ed47fa66e3 100644 --- a/apps/sim/app/api/table/[tableId]/import-async/route.test.ts +++ b/apps/sim/app/api/table/[tableId]/import-async/route.test.ts @@ -16,7 +16,7 @@ vi.mock('@sim/utils/id', () => ({ generateId: vi.fn().mockReturnValue('import-id-xyz'), generateShortId: vi.fn().mockReturnValue('short-id'), })) -vi.mock('@/lib/table/service', () => ({ markTableImporting: mockMarkTableImporting })) +vi.mock('@/lib/table/service', () => ({ markTableJobRunning: mockMarkTableImporting })) vi.mock('@/lib/table/import-runner', () => ({ runTableImport: mockRunTableImport })) vi.mock('@/lib/core/utils/background', () => ({ runDetached: (_label: string, work: () => Promise) => { @@ -92,7 +92,7 @@ describe('POST /api/table/[tableId]/import-async', () => { expect(response.status).toBe(200) expect(data.data).toEqual({ tableId: 'tbl_1', importId: 'import-id-xyz' }) - expect(mockMarkTableImporting).toHaveBeenCalledWith('tbl_1', 'import-id-xyz') + expect(mockMarkTableImporting).toHaveBeenCalledWith('tbl_1', 'import-id-xyz', 'import') expect(mockRunTableImport).toHaveBeenCalledWith( expect.objectContaining({ tableId: 'tbl_1', diff --git a/apps/sim/app/api/table/[tableId]/import-async/route.ts b/apps/sim/app/api/table/[tableId]/import-async/route.ts index 46190cbfb06..235345cfe19 100644 --- a/apps/sim/app/api/table/[tableId]/import-async/route.ts +++ b/apps/sim/app/api/table/[tableId]/import-async/route.ts @@ -4,11 +4,12 @@ import { type NextRequest, NextResponse } from 'next/server' import { importIntoTableAsyncContract } from '@/lib/api/contracts/tables' import { parseRequest } from '@/lib/api/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' +import { isTriggerDevEnabled } from '@/lib/core/config/feature-flags' import { runDetached } from '@/lib/core/utils/background' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { runTableImport } from '@/lib/table/import-runner' -import { markTableImporting } from '@/lib/table/service' +import { runTableImport, type TableImportPayload } from '@/lib/table/import-runner' +import { markTableJobRunning } from '@/lib/table/service' import { accessError, checkAccess } from '@/app/api/table/utils' const logger = createLogger('TableImportIntoAsync') @@ -56,31 +57,49 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro } const delimiter = ext === 'tsv' ? '\t' : ',' - // Atomically claim the table — the single concurrency gate. If another import already holds it, - // this returns false (no overlapping workers writing colliding row positions). + // Atomically claim the table's job slot — the single concurrency gate. If another job (import + // or delete) already holds it, this returns false (no overlapping workers). const importId = generateId() - const claimed = await markTableImporting(tableId, importId) + const claimed = await markTableJobRunning(tableId, importId, 'import') if (!claimed) { return NextResponse.json( - { error: 'An import is already in progress for this table' }, + { error: 'A job is already in progress for this table' }, { status: 409 } ) } - runDetached('table-import', () => - runTableImport({ - importId, - tableId, - workspaceId, - userId, - fileKey, - fileName, - delimiter, - mode, - mapping, - createColumns, - }) - ) + const importPayload: TableImportPayload = { + importId, + tableId, + workspaceId, + userId, + fileKey, + fileName, + delimiter, + mode, + mapping, + createColumns, + } + if (isTriggerDevEnabled) { + // Trigger.dev runs the import outside the web container, so it survives app deploys. + try { + const [{ tableImportTask }, { tasks }] = await Promise.all([ + import('@/background/table-import'), + import('@trigger.dev/sdk'), + ]) + await tasks.trigger('table-import', importPayload, { + tags: [`tableId:${tableId}`, `jobId:${importId}`], + }) + } catch (error) { + // A failed dispatch must not leave a ghost `running` job holding the + // table's one-write-job slot until the stale-job janitor fires. + const { releaseJobClaim } = await import('@/lib/table/service') + await releaseJobClaim(tableId, importId).catch(() => {}) + throw error + } + } else { + runDetached('table-import', () => runTableImport(importPayload)) + } logger.info(`[${requestId}] Async CSV import into existing table started`, { tableId, diff --git a/apps/sim/app/api/table/[tableId]/import/route.test.ts b/apps/sim/app/api/table/[tableId]/import/route.test.ts index ac3e1221924..76650baf4c1 100644 --- a/apps/sim/app/api/table/[tableId]/import/route.test.ts +++ b/apps/sim/app/api/table/[tableId]/import/route.test.ts @@ -55,8 +55,8 @@ vi.mock('@/lib/table/service', () => ({ importAppendRows: mockImportAppendRows, importReplaceRows: mockImportReplaceRows, dispatchAfterBatchInsert: mockDispatchAfterBatchInsert, - markTableImporting: mockMarkTableImporting, - releaseImportClaim: mockReleaseImportClaim, + markTableJobRunning: mockMarkTableImporting, + releaseJobClaim: mockReleaseImportClaim, })) import { POST } from '@/app/api/table/[tableId]/import/route' @@ -184,7 +184,7 @@ describe('POST /api/table/[tableId]/import', () => { it('releases the import claim after a successful write', async () => { const response = await callPost(createFormData(createCsvFile('name,age\nAlice,30'))) expect(response.status).toBe(200) - expect(mockMarkTableImporting).toHaveBeenCalledWith('tbl_1', 'deadbeefcafef00d') + expect(mockMarkTableImporting).toHaveBeenCalledWith('tbl_1', 'deadbeefcafef00d', 'import') expect(mockReleaseImportClaim).toHaveBeenCalledWith('tbl_1', 'deadbeefcafef00d') }) diff --git a/apps/sim/app/api/table/[tableId]/import/route.ts b/apps/sim/app/api/table/[tableId]/import/route.ts index f04827d1ab1..ef57b09aced 100644 --- a/apps/sim/app/api/table/[tableId]/import/route.ts +++ b/apps/sim/app/api/table/[tableId]/import/route.ts @@ -28,8 +28,8 @@ import { importAppendRows, importReplaceRows, inferColumnType, - markTableImporting, - releaseImportClaim, + markTableJobRunning, + releaseJobClaim, sanitizeName, type TableDefinition, type TableSchema, @@ -128,11 +128,11 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro if (table.archivedAt) { return NextResponse.json({ error: 'Cannot import into an archived table' }, { status: 400 }) } - // Don't run a sync import on top of an in-flight background import — concurrent writers + // Don't run a sync import on top of an in-flight background job — concurrent writers // would insert at colliding row positions. - if (table.importStatus === 'importing') { + if (table.jobStatus === 'running') { return NextResponse.json( - { error: 'An import is already in progress for this table' }, + { error: 'A job is already in progress for this table' }, { status: 409 } ) } @@ -253,12 +253,12 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro // Atomically claim the table before writing. The pre-check above reads a checkAccess snapshot // taken before the parse/validation; a background import could claim the table in that window. - // markTableImporting is the single atomic gate (same one the async kickoff uses) — released in + // markTableJobRunning is the single atomic gate (same one the async kickoff uses) — released in // the finally so a sync import can't write concurrently with a background one (corrupts replace). const syncImportId = generateId() - if (!(await markTableImporting(tableId, syncImportId))) { + if (!(await markTableJobRunning(tableId, syncImportId, 'import'))) { return NextResponse.json( - { error: 'An import is already in progress for this table' }, + { error: 'A job is already in progress for this table' }, { status: 409 } ) } @@ -399,6 +399,6 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro } finally { fileStream?.destroy() // Release before the response returns, so a client refetch never observes the transient claim. - if (claimedImportId) await releaseImportClaim(tableId, claimedImportId).catch(() => {}) + if (claimedImportId) await releaseJobClaim(tableId, claimedImportId).catch(() => {}) } }) diff --git a/apps/sim/app/api/table/[tableId]/import/cancel/route.test.ts b/apps/sim/app/api/table/[tableId]/job/cancel/route.test.ts similarity index 61% rename from apps/sim/app/api/table/[tableId]/import/cancel/route.test.ts rename to apps/sim/app/api/table/[tableId]/job/cancel/route.test.ts index d45baae77e2..f1837b42dc7 100644 --- a/apps/sim/app/api/table/[tableId]/import/cancel/route.test.ts +++ b/apps/sim/app/api/table/[tableId]/job/cancel/route.test.ts @@ -6,13 +6,19 @@ import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' import type { TableDefinition } from '@/lib/table' -const { mockCheckAccess, mockMarkImportCanceled, mockAppendTableEvent } = vi.hoisted(() => ({ - mockCheckAccess: vi.fn(), - mockMarkImportCanceled: vi.fn(), - mockAppendTableEvent: vi.fn(), -})) +const { mockCheckAccess, mockMarkJobCanceled, mockGetTableJob, mockAppendTableEvent } = vi.hoisted( + () => ({ + mockCheckAccess: vi.fn(), + mockMarkJobCanceled: vi.fn(), + mockGetTableJob: vi.fn(), + mockAppendTableEvent: vi.fn(), + }) +) -vi.mock('@/lib/table/service', () => ({ markImportCanceled: mockMarkImportCanceled })) +vi.mock('@/lib/table/service', () => ({ + markJobCanceled: mockMarkJobCanceled, + getTableJob: mockGetTableJob, +})) vi.mock('@/lib/table/events', () => ({ appendTableEvent: mockAppendTableEvent })) vi.mock('@/app/api/table/utils', async () => { const { NextResponse } = await import('next/server') @@ -23,14 +29,14 @@ vi.mock('@/app/api/table/utils', async () => { } }) -import { POST } from '@/app/api/table/[tableId]/import/cancel/route' +import { POST } from '@/app/api/table/[tableId]/job/cancel/route' function buildTable(overrides: Partial = {}): TableDefinition { return { id: 'tbl_1', name: 'People', description: null, - schema: { columns: [{ name: 'name', type: 'string' }] }, + schema: { columns: [] }, metadata: null, rowCount: 0, maxRows: 1_000_000, @@ -44,7 +50,7 @@ function buildTable(overrides: Partial = {}): TableDefinition { } function makeRequest(body: unknown, tableId = 'tbl_1') { - const req = new NextRequest(`http://localhost:3000/api/table/${tableId}/import/cancel`, { + const req = new NextRequest(`http://localhost:3000/api/table/${tableId}/job/cancel`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body), @@ -52,9 +58,9 @@ function makeRequest(body: unknown, tableId = 'tbl_1') { return POST(req, { params: Promise.resolve({ tableId }) }) } -const validBody = { workspaceId: 'workspace-1', importId: 'import-id-xyz' } +const validBody = { workspaceId: 'workspace-1', jobId: 'job_1' } -describe('POST /api/table/[tableId]/import/cancel', () => { +describe('POST /api/table/[tableId]/job/cancel', () => { beforeEach(() => { vi.clearAllMocks() hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({ @@ -63,27 +69,31 @@ describe('POST /api/table/[tableId]/import/cancel', () => { authType: 'session', }) mockCheckAccess.mockResolvedValue({ ok: true, table: buildTable() }) - mockMarkImportCanceled.mockResolvedValue(true) + mockMarkJobCanceled.mockResolvedValue(true) + mockGetTableJob.mockResolvedValue({ + id: 'job_1', + type: 'delete', + status: 'running', + payload: null, + }) }) - it('cancels the import and emits a canceled event', async () => { + it('cancels the job and emits a typed cancel event', async () => { const response = await makeRequest(validBody) const data = await response.json() expect(response.status).toBe(200) expect(data.data).toEqual({ canceled: true }) - expect(mockMarkImportCanceled).toHaveBeenCalledWith('tbl_1', 'import-id-xyz') + expect(mockMarkJobCanceled).toHaveBeenCalledWith('tbl_1', 'job_1') expect(mockAppendTableEvent).toHaveBeenCalledWith( - expect.objectContaining({ kind: 'import', status: 'canceled', importId: 'import-id-xyz' }) + expect.objectContaining({ kind: 'job', type: 'delete', status: 'canceled', jobId: 'job_1' }) ) }) - it('does not emit an event when nothing was importing', async () => { - mockMarkImportCanceled.mockResolvedValue(false) + it('does not emit an event when nothing was running', async () => { + mockMarkJobCanceled.mockResolvedValue(false) const response = await makeRequest(validBody) const data = await response.json() - - expect(response.status).toBe(200) expect(data.data).toEqual({ canceled: false }) expect(mockAppendTableEvent).not.toHaveBeenCalled() }) @@ -92,19 +102,12 @@ describe('POST /api/table/[tableId]/import/cancel', () => { hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({ success: false }) const response = await makeRequest(validBody) expect(response.status).toBe(401) - expect(mockMarkImportCanceled).not.toHaveBeenCalled() - }) - - it('returns the access error status when access is denied', async () => { - mockCheckAccess.mockResolvedValue({ ok: false, status: 403 }) - const response = await makeRequest(validBody) - expect(response.status).toBe(403) + expect(mockMarkJobCanceled).not.toHaveBeenCalled() }) it('returns 400 on workspace mismatch', async () => { - mockCheckAccess.mockResolvedValue({ ok: true, table: buildTable({ workspaceId: 'other-ws' }) }) - const response = await makeRequest(validBody) + const response = await makeRequest({ ...validBody, workspaceId: 'other' }) expect(response.status).toBe(400) - expect(mockMarkImportCanceled).not.toHaveBeenCalled() + expect(mockMarkJobCanceled).not.toHaveBeenCalled() }) }) diff --git a/apps/sim/app/api/table/[tableId]/import/cancel/route.ts b/apps/sim/app/api/table/[tableId]/job/cancel/route.ts similarity index 55% rename from apps/sim/app/api/table/[tableId]/import/cancel/route.ts rename to apps/sim/app/api/table/[tableId]/job/cancel/route.ts index 62ab7310f47..b4ee3d98346 100644 --- a/apps/sim/app/api/table/[tableId]/import/cancel/route.ts +++ b/apps/sim/app/api/table/[tableId]/job/cancel/route.ts @@ -1,15 +1,16 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' -import { cancelTableImportContract } from '@/lib/api/contracts/tables' +import { cancelTableJobContract } from '@/lib/api/contracts/tables' import { parseRequest } from '@/lib/api/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { appendTableEvent } from '@/lib/table/events' -import { markImportCanceled } from '@/lib/table/service' +import { getTableJob, markJobCanceled } from '@/lib/table/service' +import type { TableJobType } from '@/lib/table/types' import { accessError, checkAccess } from '@/app/api/table/utils' -const logger = createLogger('TableImportCancelAPI') +const logger = createLogger('TableJobCancelAPI') export const runtime = 'nodejs' export const dynamic = 'force-dynamic' @@ -19,11 +20,11 @@ interface RouteParams { } /** - * POST /api/table/[tableId]/import/cancel + * POST /api/table/[tableId]/job/cancel * - * Cancels an in-flight async CSV import. Flips the table's import status to `canceled`, which makes - * the detached worker's next ownership check fail so it stops inserting. Committed rows are left in - * place (no rollback) — the user can delete the table. No-op if the import already finished. + * Cancels an in-flight async table job (import or delete). Flips the table's job status to + * `canceled`, which makes the detached worker's next ownership check fail so it stops. Committed + * work (inserted/deleted rows) is left in place (no rollback). No-op if the job already finished. */ export const POST = withRouteHandler(async (request: NextRequest, { params }: RouteParams) => { const requestId = generateRequestId() @@ -33,10 +34,10 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) } - const parsed = await parseRequest(cancelTableImportContract, request, { params }) + const parsed = await parseRequest(cancelTableJobContract, request, { params }) if (!parsed.success) return parsed.response const { tableId } = parsed.data.params - const { workspaceId, importId } = parsed.data.body + const { workspaceId, jobId } = parsed.data.body const access = await checkAccess(tableId, authResult.userId, 'write') if (!access.ok) return accessError(access, requestId, tableId) @@ -44,11 +45,16 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) } - const canceled = await markImportCanceled(tableId, importId) + // Resolve the job's actual type (from its own row — the table-level derivation excludes + // exports) so the cancel event carries the right `type`. + const job = await getTableJob(tableId, jobId) + const type = (job?.type ?? 'import') as TableJobType + + const canceled = await markJobCanceled(tableId, jobId) if (canceled) { - void appendTableEvent({ kind: 'import', tableId, importId, status: 'canceled' }) + void appendTableEvent({ kind: 'job', type, tableId, jobId, status: 'canceled' }) } - logger.info(`[${requestId}] Import cancel requested`, { tableId, importId, canceled }) + logger.info(`[${requestId}] Job cancel requested`, { tableId, jobId, type, canceled }) return NextResponse.json({ success: true, data: { canceled } }) }) diff --git a/apps/sim/app/api/table/[tableId]/route.ts b/apps/sim/app/api/table/[tableId]/route.ts index c0b018f854e..0d185a74784 100644 --- a/apps/sim/app/api/table/[tableId]/route.ts +++ b/apps/sim/app/api/table/[tableId]/route.ts @@ -68,10 +68,11 @@ export const GET = withRouteHandler(async (request: NextRequest, { params }: Tab table.updatedAt instanceof Date ? table.updatedAt.toISOString() : String(table.updatedAt), - importStatus: table.importStatus ?? null, - importId: table.importId ?? null, - importError: table.importError ?? null, - importRowsProcessed: table.importRowsProcessed ?? 0, + jobStatus: table.jobStatus ?? null, + jobId: table.jobId ?? null, + jobType: table.jobType ?? null, + jobError: table.jobError ?? null, + jobRowsProcessed: table.jobRowsProcessed ?? 0, }, }, }) diff --git a/apps/sim/app/api/table/[tableId]/rows/[rowId]/route.ts b/apps/sim/app/api/table/[tableId]/rows/[rowId]/route.ts index 13a0762c68b..0910c894e4b 100644 --- a/apps/sim/app/api/table/[tableId]/rows/[rowId]/route.ts +++ b/apps/sim/app/api/table/[tableId]/rows/[rowId]/route.ts @@ -15,7 +15,12 @@ import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import type { RowData } from '@/lib/table' import { deleteRow, updateRow } from '@/lib/table' -import { accessError, checkAccess } from '@/app/api/table/utils' +import { + accessError, + checkAccess, + rootErrorMessage, + rowWriteErrorResponse, +} from '@/app/api/table/utils' const logger = createLogger('TableRowAPI') @@ -163,21 +168,12 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: RowR }, }) } catch (error) { - const errorMessage = toError(error).message - - if (errorMessage === 'Row not found') { - return NextResponse.json({ error: errorMessage }, { status: 404 }) + if (rootErrorMessage(error) === 'Row not found') { + return NextResponse.json({ error: 'Row not found' }, { status: 404 }) } - if ( - errorMessage.includes('Row size exceeds') || - errorMessage.includes('Schema validation') || - errorMessage.includes('must be unique') || - errorMessage.includes('Unique constraint violation') || - errorMessage.includes('Cannot set unique column') - ) { - return NextResponse.json({ error: errorMessage }, { status: 400 }) - } + const response = rowWriteErrorResponse(error) + if (response) return response logger.error(`[${requestId}] Error updating row:`, error) return NextResponse.json({ error: 'Failed to update row' }, { status: 500 }) diff --git a/apps/sim/app/api/table/[tableId]/rows/route.ts b/apps/sim/app/api/table/[tableId]/rows/route.ts index 7b27e463c5d..4ef110ae1b3 100644 --- a/apps/sim/app/api/table/[tableId]/rows/route.ts +++ b/apps/sim/app/api/table/[tableId]/rows/route.ts @@ -1,5 +1,4 @@ import { createLogger } from '@sim/logger' -import { toError } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' import { type BatchInsertTableRowsBodyInput, @@ -14,7 +13,7 @@ import { isZodError, validationErrorResponse } from '@/lib/api/server/validation import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import type { Filter, RowData, Sort, TableSchema } from '@/lib/table' +import type { Filter, RowData, Sort, TableRowsCursor, TableSchema } from '@/lib/table' import { batchInsertRows, batchUpdateRows, @@ -28,7 +27,7 @@ import { } from '@/lib/table' import { queryRows } from '@/lib/table/service' import { TableQueryValidationError } from '@/lib/table/sql' -import { accessError, checkAccess } from '@/app/api/table/utils' +import { accessError, checkAccess, rowWriteErrorResponse } from '@/app/api/table/utils' const logger = createLogger('TableRowsAPI') @@ -93,18 +92,8 @@ async function handleBatchInsert( }, }) } catch (error) { - const errorMessage = toError(error).message - - if ( - errorMessage.includes('row limit') || - errorMessage.includes('Insufficient capacity') || - errorMessage.includes('Schema validation') || - errorMessage.includes('must be unique') || - errorMessage.includes('Row size exceeds') || - errorMessage.match(/^Row \d+:/) - ) { - return NextResponse.json({ error: errorMessage }, { status: 400 }) - } + const response = rowWriteErrorResponse(error) + if (response) return response logger.error(`[${requestId}] Error batch inserting rows:`, error) return NextResponse.json({ error: 'Failed to insert rows' }, { status: 500 }) @@ -191,17 +180,8 @@ export const POST = withRouteHandler( return validationErrorResponse(error) } - const errorMessage = toError(error).message - - if ( - errorMessage.includes('row limit') || - errorMessage.includes('Insufficient capacity') || - errorMessage.includes('Schema validation') || - errorMessage.includes('must be unique') || - errorMessage.includes('Row size exceeds') - ) { - return NextResponse.json({ error: errorMessage }, { status: 400 }) - } + const response = rowWriteErrorResponse(error) + if (response) return response logger.error(`[${requestId}] Error inserting row:`, error) return NextResponse.json({ error: 'Failed to insert row' }, { status: 500 }) @@ -225,12 +205,14 @@ export const GET = withRouteHandler( const workspaceId = searchParams.get('workspaceId') const filterParam = searchParams.get('filter') const sortParam = searchParams.get('sort') + const afterParam = searchParams.get('after') const limit = searchParams.get('limit') const offset = searchParams.get('offset') const includeTotalParam = searchParams.get('includeTotal') let filter: Record | undefined let sort: Sort | undefined + let after: TableRowsCursor | undefined try { if (filterParam) { @@ -239,14 +221,18 @@ export const GET = withRouteHandler( if (sortParam) { sort = JSON.parse(sortParam) as Sort } + if (afterParam) { + after = JSON.parse(afterParam) as TableRowsCursor + } } catch { - return NextResponse.json({ error: 'Invalid filter or sort JSON' }, { status: 400 }) + return NextResponse.json({ error: 'Invalid filter, sort, or after JSON' }, { status: 400 }) } const validated = tableRowsQuerySchema.parse({ workspaceId, filter, sort, + after, limit, offset, includeTotal: includeTotalParam, @@ -271,6 +257,7 @@ export const GET = withRouteHandler( sort: validated.sort, limit: validated.limit, offset: validated.offset, + after: validated.after, includeTotal: validated.includeTotal, }, requestId @@ -393,18 +380,8 @@ export const PUT = withRouteHandler( return NextResponse.json({ error: error.message }, { status: 400 }) } - const errorMessage = toError(error).message - - if ( - errorMessage.includes('Row size exceeds') || - errorMessage.includes('Schema validation') || - errorMessage.includes('must be unique') || - errorMessage.includes('Unique constraint violation') || - errorMessage.includes('Cannot set unique column') || - errorMessage.includes('Filter is required') - ) { - return NextResponse.json({ error: errorMessage }, { status: 400 }) - } + const response = rowWriteErrorResponse(error) + if (response) return response logger.error(`[${requestId}] Error updating rows by filter:`, error) return NextResponse.json({ error: 'Failed to update rows' }, { status: 500 }) @@ -495,11 +472,8 @@ export const DELETE = withRouteHandler( return NextResponse.json({ error: error.message }, { status: 400 }) } - const errorMessage = toError(error).message - - if (errorMessage.includes('Filter is required')) { - return NextResponse.json({ error: errorMessage }, { status: 400 }) - } + const response = rowWriteErrorResponse(error) + if (response) return response logger.error(`[${requestId}] Error deleting rows:`, error) return NextResponse.json({ error: 'Failed to delete rows' }, { status: 500 }) @@ -564,22 +538,8 @@ export const PATCH = withRouteHandler( return validationErrorResponse(error) } - const errorMessage = toError(error).message - - if ( - errorMessage.includes('Row size exceeds') || - errorMessage.includes('Schema validation') || - errorMessage.includes('must be valid') || - errorMessage.includes('must be string') || - errorMessage.includes('must be number') || - errorMessage.includes('must be boolean') || - errorMessage.includes('must be unique') || - errorMessage.includes('Unique constraint violation') || - errorMessage.includes('Cannot set unique column') || - errorMessage.includes('Rows not found') - ) { - return NextResponse.json({ error: errorMessage }, { status: 400 }) - } + const response = rowWriteErrorResponse(error) + if (response) return response logger.error(`[${requestId}] Error batch updating rows:`, error) return NextResponse.json({ error: 'Failed to update rows' }, { status: 500 }) diff --git a/apps/sim/app/api/table/[tableId]/rows/upsert/route.ts b/apps/sim/app/api/table/[tableId]/rows/upsert/route.ts index c34ae686c0b..c6253c0aeaa 100644 --- a/apps/sim/app/api/table/[tableId]/rows/upsert/route.ts +++ b/apps/sim/app/api/table/[tableId]/rows/upsert/route.ts @@ -1,5 +1,4 @@ import { createLogger } from '@sim/logger' -import { toError } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' import { upsertTableRowContract } from '@/lib/api/contracts/tables' import { parseRequest } from '@/lib/api/server' @@ -9,7 +8,7 @@ import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import type { RowData } from '@/lib/table' import { upsertRow } from '@/lib/table' -import { accessError, checkAccess } from '@/app/api/table/utils' +import { accessError, checkAccess, rowWriteErrorResponse } from '@/app/api/table/utils' const logger = createLogger('TableUpsertAPI') @@ -77,19 +76,8 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Upser return validationErrorResponse(error) } - const errorMessage = toError(error).message - - if ( - errorMessage.includes('unique column') || - errorMessage.includes('Unique constraint violation') || - errorMessage.includes('conflictTarget') || - errorMessage.includes('row limit') || - errorMessage.includes('Schema validation') || - errorMessage.includes('Upsert requires') || - errorMessage.includes('Row size exceeds') - ) { - return NextResponse.json({ error: errorMessage }, { status: 400 }) - } + const response = rowWriteErrorResponse(error) + if (response) return response logger.error(`[${requestId}] Error upserting row:`, error) return NextResponse.json({ error: 'Failed to upsert row' }, { status: 500 }) diff --git a/apps/sim/app/api/table/import-async/route.test.ts b/apps/sim/app/api/table/import-async/route.test.ts index 8ecdd2a923a..eaaf90597cc 100644 --- a/apps/sim/app/api/table/import-async/route.test.ts +++ b/apps/sim/app/api/table/import-async/route.test.ts @@ -84,7 +84,7 @@ describe('POST /api/table/import-async', () => { expect(response.status).toBe(200) expect(data.data).toEqual({ tableId: 'tbl_async', importId: 'import-id-123' }) expect(mockCreateTable).toHaveBeenCalledWith( - expect.objectContaining({ importStatus: 'importing', importId: 'import-id-123' }), + expect.objectContaining({ jobStatus: 'running', jobType: 'import', jobId: 'import-id-123' }), expect.any(String) ) expect(mockRunTableImport).toHaveBeenCalledWith( diff --git a/apps/sim/app/api/table/import-async/route.ts b/apps/sim/app/api/table/import-async/route.ts index 239268053e7..2a2977c0b00 100644 --- a/apps/sim/app/api/table/import-async/route.ts +++ b/apps/sim/app/api/table/import-async/route.ts @@ -4,6 +4,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { importTableAsyncContract } from '@/lib/api/contracts/tables' import { parseRequest } from '@/lib/api/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' +import { isTriggerDevEnabled } from '@/lib/core/config/feature-flags' import { runDetached } from '@/lib/core/utils/background' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -16,7 +17,7 @@ import { TABLE_LIMITS, TableConflictError, } from '@/lib/table' -import { runTableImport } from '@/lib/table/import-runner' +import { runTableImport, type TableImportPayload } from '@/lib/table/import-runner' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' const logger = createLogger('TableImportAsync') @@ -83,8 +84,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { userId, maxRows: planLimits.maxRowsPerTable, maxTables: planLimits.maxTables, - importStatus: 'importing', - importId, + jobStatus: 'running', + jobType: 'import', + jobId: importId, }, requestId ) @@ -98,18 +100,36 @@ export const POST = withRouteHandler(async (request: NextRequest) => { throw error } - runDetached('table-import', () => - runTableImport({ - importId, - tableId: table.id, - workspaceId, - userId, - fileKey, - fileName, - delimiter, - mode: 'create', - }) - ) + const importPayload: TableImportPayload = { + importId, + tableId: table.id, + workspaceId, + userId, + fileKey, + fileName, + delimiter, + mode: 'create', + } + if (isTriggerDevEnabled) { + // Trigger.dev runs the import outside the web container, so it survives app deploys. + try { + const [{ tableImportTask }, { tasks }] = await Promise.all([ + import('@/background/table-import'), + import('@trigger.dev/sdk'), + ]) + await tasks.trigger('table-import', importPayload, { + tags: [`tableId:${table.id}`, `jobId:${importId}`], + }) + } catch (error) { + // A failed dispatch must not leave a ghost `running` job holding the + // table's one-write-job slot until the stale-job janitor fires. + const { releaseJobClaim } = await import('@/lib/table/service') + await releaseJobClaim(table.id, importId).catch(() => {}) + throw error + } + } else { + runDetached('table-import', () => runTableImport(importPayload)) + } captureServerEvent( userId, diff --git a/apps/sim/app/api/table/jobs/route.ts b/apps/sim/app/api/table/jobs/route.ts new file mode 100644 index 00000000000..912d769c39f --- /dev/null +++ b/apps/sim/app/api/table/jobs/route.ts @@ -0,0 +1,42 @@ +import { createLogger } from '@sim/logger' +import { type NextRequest, NextResponse } from 'next/server' +import { listTableJobsContract } from '@/lib/api/contracts/tables' +import { parseRequest } from '@/lib/api/server' +import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { listWorkspaceExportJobs } from '@/lib/table/service' +import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' + +const logger = createLogger('TableJobsAPI') + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +/** + * GET /api/table/jobs?workspaceId=…&type=export + * + * Lists a workspace's export jobs (running + recently finished) for the header tray. Exports are + * excluded from the table-level job derivation, so the tray reads them here. + */ +export const GET = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + + const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) + if (!authResult.success || !authResult.userId) { + return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) + } + + const parsed = await parseRequest(listTableJobsContract, request, {}) + if (!parsed.success) return parsed.response + const { workspaceId } = parsed.data.query + + const { hasAccess } = await checkWorkspaceAccess(workspaceId, authResult.userId) + if (!hasAccess) { + return NextResponse.json({ error: 'Access denied' }, { status: 403 }) + } + + const jobs = await listWorkspaceExportJobs(workspaceId) + logger.info(`[${requestId}] Listed ${jobs.length} export jobs`, { workspaceId }) + return NextResponse.json({ success: true, data: { jobs } }) +}) diff --git a/apps/sim/app/api/table/route.ts b/apps/sim/app/api/table/route.ts index ed41a7813d6..94aa8c45b4c 100644 --- a/apps/sim/app/api/table/route.ts +++ b/apps/sim/app/api/table/route.ts @@ -217,10 +217,11 @@ export const GET = withRouteHandler(async (request: NextRequest) => { : t.archivedAt ? String(t.archivedAt) : null, - importStatus: t.importStatus ?? null, - importId: t.importId ?? null, - importError: t.importError ?? null, - importRowsProcessed: t.importRowsProcessed ?? 0, + jobStatus: t.jobStatus ?? null, + jobId: t.jobId ?? null, + jobType: t.jobType ?? null, + jobError: t.jobError ?? null, + jobRowsProcessed: t.jobRowsProcessed ?? 0, } }) diff --git a/apps/sim/app/api/table/utils.test.ts b/apps/sim/app/api/table/utils.test.ts new file mode 100644 index 00000000000..df1a05e7c73 --- /dev/null +++ b/apps/sim/app/api/table/utils.test.ts @@ -0,0 +1,55 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { rootErrorMessage, rowWriteErrorResponse } from '@/app/api/table/utils' + +/** Mimics drizzle's DrizzleQueryError: message is the failed SQL, real error on `cause`. */ +function wrapLikeDrizzle(cause: Error): Error { + return new Error('Failed query: insert into "user_table_rows" ...', { cause }) +} + +describe('rootErrorMessage', () => { + it('returns the message of a plain error', () => { + expect(rootErrorMessage(new Error('Schema validation failed: bad'))).toBe( + 'Schema validation failed: bad' + ) + }) + + it('unwraps the cause chain to the deepest error', () => { + const root = new Error('Maximum row limit (10000) reached for table tbl_abc') + expect(rootErrorMessage(wrapLikeDrizzle(root))).toBe(root.message) + }) + + it('stringifies non-Error values', () => { + expect(rootErrorMessage('boom')).toBe('boom') + }) +}) + +describe('rowWriteErrorResponse', () => { + it('rewrites the DB row-limit trigger error into a friendly 400', async () => { + const error = wrapLikeDrizzle( + new Error('Maximum row limit (10000) reached for table tbl_2b15ec29647040e7b8eb5d2949f556cf') + ) + const response = rowWriteErrorResponse(error) + expect(response?.status).toBe(400) + const body = await response?.json() + expect(body.error).toBe('Row limit exceeded — this table is capped at 10,000 rows') + }) + + it('passes known validation messages through as 400', async () => { + const response = rowWriteErrorResponse(new Error('Value for column "email" must be unique')) + expect(response?.status).toBe(400) + const body = await response?.json() + expect(body.error).toBe('Value for column "email" must be unique') + }) + + it('matches per-row batch validation messages', () => { + expect(rowWriteErrorResponse(new Error('Row 3: name is required'))?.status).toBe(400) + }) + + it('returns null for unknown errors so callers keep their generic 500', () => { + expect(rowWriteErrorResponse(new Error('connection refused'))).toBeNull() + expect(rowWriteErrorResponse(wrapLikeDrizzle(new Error('deadlock detected')))).toBeNull() + }) +}) diff --git a/apps/sim/app/api/table/utils.ts b/apps/sim/app/api/table/utils.ts index 41a66e85bb3..c8dde913132 100644 --- a/apps/sim/app/api/table/utils.ts +++ b/apps/sim/app/api/table/utils.ts @@ -1,4 +1,5 @@ import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' import { NextResponse } from 'next/server' import { createTableColumnBodySchema, @@ -6,12 +7,97 @@ import { updateTableColumnBodySchema, } from '@/lib/api/contracts/tables' import type { MultipartError } from '@/lib/core/utils/multipart' -import type { ColumnDefinition, TableDefinition } from '@/lib/table' -import { getTableById } from '@/lib/table' +import type { ColumnDefinition, Filter, TableDefinition } from '@/lib/table' +import { buildFilterClause, getTableById, TableQueryValidationError } from '@/lib/table' +import { USER_TABLE_ROWS_SQL_NAME } from '@/lib/table/constants' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' +/** + * Validates a `filter` against the table's column schema, returning a 400 response on a bad field + * (or `null` when the filter is valid or absent). Shared by the routes that accept a filter + * (`delete-async`, `columns/run`) so a bad field fails fast with a clear message. + */ +export function tableFilterError( + filter: Filter | undefined, + columns: ColumnDefinition[] +): NextResponse | null { + if (!filter) return null + try { + buildFilterClause(filter, USER_TABLE_ROWS_SQL_NAME, columns) + return null + } catch (error) { + if (error instanceof TableQueryValidationError) { + return NextResponse.json({ error: error.message }, { status: 400 }) + } + throw error + } +} + const logger = createLogger('TableUtils') +/** + * Deepest `Error` message in the cause chain. Drizzle wraps DB errors (e.g. the + * row-limit trigger's RAISE) in a `DrizzleQueryError` whose own message is just + * the failed SQL — substring classification must look at the root cause. + */ +export function rootErrorMessage(error: unknown): string { + let current: unknown = error + while (current instanceof Error && current.cause instanceof Error) { + current = current.cause + } + return toError(current).message +} + +/** + * Known user-facing row-write failures (service validation + the DB row-limit + * trigger). Anything outside this list stays a generic 500 — unknown errors can + * carry SQL/internals that don't belong in a toast. + */ +const ROW_WRITE_ERROR_PATTERNS = [ + 'row limit', + 'Insufficient capacity', + 'Schema validation', + 'must be unique', + 'must be valid', + 'must be string', + 'must be number', + 'must be boolean', + 'unique column', + 'Unique constraint violation', + 'Row size exceeds', + 'conflictTarget', + 'Upsert requires', + 'Rows not found', + 'Filter is required', +] as const + +/** + * Maps a known user-facing row-write failure to a 400 carrying the real message + * (so client toasts can show the actual reason); `null` when the error is + * unrecognized and the caller should log it and return its generic 500. + */ +export function rowWriteErrorResponse(error: unknown): NextResponse | null { + const message = rootErrorMessage(error) + + // Trigger message reads `Maximum row limit (N) reached for table tbl_...` — + // rewrite it for the toast instead of leaking the internal table id. + const limitMatch = message.match(/Maximum row limit \((\d+)\) reached/) + if (limitMatch) { + return NextResponse.json( + { + error: `Row limit exceeded — this table is capped at ${Number(limitMatch[1]).toLocaleString('en-US')} rows`, + }, + { status: 400 } + ) + } + + if (ROW_WRITE_ERROR_PATTERNS.some((p) => message.includes(p)) || /^Row .+?:/.test(message)) { + return NextResponse.json({ error: message }, { status: 400 }) + } + + return null +} + /** * Next.js buffers the request body for the proxy and silently truncates it past this * size (`experimental.proxyClientMaxBodySize`, default 10MB). The synchronous CSV diff --git a/apps/sim/app/api/v1/tables/[tableId]/rows/route.ts b/apps/sim/app/api/v1/tables/[tableId]/rows/route.ts index 28c4e209cd0..02efd13e812 100644 --- a/apps/sim/app/api/v1/tables/[tableId]/rows/route.ts +++ b/apps/sim/app/api/v1/tables/[tableId]/rows/route.ts @@ -1,5 +1,4 @@ import { createLogger } from '@sim/logger' -import { toError } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' import { type V1BatchInsertTableRowsBody, @@ -34,7 +33,7 @@ import { } from '@/lib/table' import { queryRows } from '@/lib/table/service' import { TableQueryValidationError } from '@/lib/table/sql' -import { accessError, checkAccess } from '@/app/api/table/utils' +import { accessError, checkAccess, rowWriteErrorResponse } from '@/app/api/table/utils' import { checkRateLimit, checkWorkspaceScope, @@ -104,18 +103,8 @@ async function handleBatchInsert( }, }) } catch (error) { - const errorMessage = toError(error).message - - if ( - errorMessage.includes('row limit') || - errorMessage.includes('Insufficient capacity') || - errorMessage.includes('Schema validation') || - errorMessage.includes('must be unique') || - errorMessage.includes('Row size exceeds') || - errorMessage.match(/^Row \d+:/) - ) { - return NextResponse.json({ error: errorMessage }, { status: 400 }) - } + const response = rowWriteErrorResponse(error) + if (response) return response logger.error(`[${requestId}] Error batch inserting rows:`, error) return NextResponse.json({ error: 'Failed to insert rows' }, { status: 500 }) @@ -287,17 +276,8 @@ export const POST = withRouteHandler( const validationResponse = validationErrorResponseFromError(error) if (validationResponse) return validationResponse - const errorMessage = toError(error).message - - if ( - errorMessage.includes('row limit') || - errorMessage.includes('Insufficient capacity') || - errorMessage.includes('Schema validation') || - errorMessage.includes('must be unique') || - errorMessage.includes('Row size exceeds') - ) { - return NextResponse.json({ error: errorMessage }, { status: 400 }) - } + const response = rowWriteErrorResponse(error) + if (response) return response logger.error(`[${requestId}] Error inserting row:`, error) return NextResponse.json({ error: 'Failed to insert row' }, { status: 500 }) @@ -381,18 +361,8 @@ export const PUT = withRouteHandler(async (request: NextRequest, context: TableR return NextResponse.json({ error: error.message }, { status: 400 }) } - const errorMessage = toError(error).message - - if ( - errorMessage.includes('Row size exceeds') || - errorMessage.includes('Schema validation') || - errorMessage.includes('must be unique') || - errorMessage.includes('Unique constraint violation') || - errorMessage.includes('Cannot set unique column') || - errorMessage.includes('Filter is required') - ) { - return NextResponse.json({ error: errorMessage }, { status: 400 }) - } + const response = rowWriteErrorResponse(error) + if (response) return response logger.error(`[${requestId}] Error updating rows by filter:`, error) return NextResponse.json({ error: 'Failed to update rows' }, { status: 500 }) @@ -478,11 +448,8 @@ export const DELETE = withRouteHandler( return NextResponse.json({ error: error.message }, { status: 400 }) } - const errorMessage = toError(error).message - - if (errorMessage.includes('Filter is required')) { - return NextResponse.json({ error: errorMessage }, { status: 400 }) - } + const response = rowWriteErrorResponse(error) + if (response) return response logger.error(`[${requestId}] Error deleting rows:`, error) return NextResponse.json({ error: 'Failed to delete rows' }, { status: 500 }) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/context-menu/context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/context-menu/context-menu.tsx index c6c069d6389..de00999f35d 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/context-menu/context-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/context-menu/context-menu.tsx @@ -73,20 +73,21 @@ export function ContextMenu({ disableInsert = false, disableDelete = false, }: ContextMenuProps) { - const deleteLabel = selectedRowCount > 1 ? `Delete ${selectedRowCount} rows` : 'Delete row' + const count = selectedRowCount.toLocaleString() + const deleteLabel = selectedRowCount > 1 ? `Delete ${count} rows` : 'Delete row' const runLabel = workflowCellScoped ? selectedRowCount > 1 - ? `Run cell on ${selectedRowCount} rows` + ? `Run cell on ${count} rows` : 'Run cell' : selectedRowCount > 1 - ? `Run empty or failed cells on ${selectedRowCount} rows` + ? `Run empty or failed cells on ${count} rows` : 'Run empty or failed cells' const refreshLabel = workflowCellScoped ? selectedRowCount > 1 - ? `Re-run cell on ${selectedRowCount} rows` + ? `Re-run cell on ${count} rows` : 'Re-run cell' : selectedRowCount > 1 - ? `Re-run all cells on ${selectedRowCount} rows` + ? `Re-run all cells on ${count} rows` : 'Re-run all cells' const stopLabel = runningInSelectionCount === 1 diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx index 9705169c036..e7152dc122d 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx @@ -11,7 +11,7 @@ import { Loader, TableX } from '@/components/emcn/icons' import type { RunLimit, RunMode, TableFindMatch } from '@/lib/api/contracts/tables' import { cn } from '@/lib/core/utils/cn' import { captureEvent } from '@/lib/posthog/client' -import type { ColumnDefinition, TableRow as TableRowType, WorkflowGroup } from '@/lib/table' +import type { ColumnDefinition, Filter, TableRow as TableRowType, WorkflowGroup } from '@/lib/table' import { getColumnId } from '@/lib/table/column-keys' import { TABLE_LIMITS } from '@/lib/table/constants' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' @@ -97,8 +97,19 @@ export interface SelectionSnapshot { /** Whether the table has any workflow-output columns (drives the Run/Stop visibility). */ hasWorkflowColumns: boolean /** Cells the Play / Refresh / Stop buttons act on. Null when the selection - * contains no workflow output cells. */ - selectedRunScope: { groupIds: string[]; rowIds: string[]; allRows: boolean } | null + * contains no workflow output cells. `rowCount` is the true selected-row total for the + * action-bar label — equal to `rowIds.length` except under select-all, where `rowIds` holds + * only the loaded (virtualized) window but `rowCount` is the table's full row count. */ + selectedRunScope: { + groupIds: string[] + rowIds: string[] + allRows: boolean + rowCount: number + /** Active filter when `allRows` is set — lets a filtered "select all" run only matching rows. */ + filter?: Filter + /** Deselected rows when `allRows` is set — runs/stops skip them. */ + excludeRowIds?: string[] + } | null /** Drives Play (`hasIncompleteOrFailed`) / Refresh (`hasCompleted`) / * Stop (`hasInFlight`) visibility on the action bar. */ selectionStats: ExecStatusMix @@ -146,16 +157,38 @@ interface TableGridProps { onOpenRowModal: (row: TableRowType) => void /** Open the row-delete modal for `snapshots`. Wrapper renders the modal. */ onRequestDeleteRows: (snapshots: DeletedRowSnapshot[]) => void + /** + * Request a background "select all" delete: the active filter (held by the wrapper) plus the + * deselected `excludeRowIds`. The wrapper confirms and kicks off the async delete job. + */ + onRequestDeleteAllByFilter: (params: { excludeRowIds: string[]; estimatedCount: number }) => void /** Open the delete-columns confirmation modal for `names`. Wrapper renders the modal. */ onRequestDeleteColumns: (names: string[]) => void - /** Fire run for a single column (meta-cell Run menu). */ - onRunColumn: (groupId: string, runMode: RunMode, rowIds?: string[], limit?: RunLimit) => void + /** Fire run for a single column (meta-cell Run menu). `filter` (mutually + * exclusive with `rowIds`) scopes a select-all run to the active filter. */ + onRunColumn: ( + groupId: string, + runMode: RunMode, + rowIds?: string[], + limit?: RunLimit, + filter?: Filter, + excludeRowIds?: string[] + ) => void /** Fire every runnable column on a single row (per-row gutter Play). */ onRunRow: (rowId: string) => void - /** Fan out a run across every workflow group on `rowIds`. Used by context menu. */ - onRunRows: (rowIds: string[], runMode: RunMode) => void + /** Fan out a run across every workflow group on `rowIds` — or, with `rowIds` + * undefined, across the whole table / the active filter. Used by context menu. */ + onRunRows: ( + rowIds: string[] | undefined, + runMode: RunMode, + filter?: Filter, + excludeRowIds?: string[] + ) => void /** Stop running workflows on `rowIds`. Per-row gutter Stop also funnels through here. */ onStopRows: (rowIds: string[]) => void + /** Select-all Stop: table-wide, or scoped to the active filter when one is set. + * `excludeRowIds` (deselected rows) keep running. */ + onStopAllRows: (filter?: Filter, excludeRowIds?: string[]) => void /** Single-row stop for the per-row gutter button. */ onStopRow: (rowId: string) => void /** @@ -177,6 +210,12 @@ interface TableGridProps { * mutation succeeds. */ afterDeleteRowsSinkRef: React.MutableRefObject<((snapshots: DeletedRowSnapshot[]) => void) | null> + /** + * Ref the grid populates with its post-select-all-delete cleanup (clear selection). The wrapper + * invokes it after the async delete job is kicked off. No undo — the deleted set isn't + * materialized client-side. + */ + afterDeleteAllSinkRef: React.MutableRefObject<(() => void) | null> /** * Ref the grid populates with its full delete-columns cascade (per-column * mutation, undo push, columnOrder + columnWidths cleanup). The wrapper's @@ -246,16 +285,19 @@ export function TableGrid({ onOpenExecutionDetails, onOpenRowModal, onRequestDeleteRows, + onRequestDeleteAllByFilter, onRequestDeleteColumns, onRunColumn, onRunRow, onRunRows, onStopRows, + onStopAllRows, onStopRow, onSelectionChange, queryOptions, columnRenameSinkRef, afterDeleteRowsSinkRef, + afterDeleteAllSinkRef, confirmDeleteColumnsSinkRef, pushTableRenameUndoSinkRef, }: TableGridProps) { @@ -331,6 +373,7 @@ export function TableGrid({ tableData, isLoadingTable, rows, + rowTotal, isLoadingRows, fetchNextPage, hasNextPage, @@ -356,8 +399,10 @@ export function TableGrid({ const totalRunning = Object.values(runningByRowId).reduce((sum, n) => sum + n, 0) const hasActiveDispatch = (activeDispatches?.length ?? 0) > 0 - const tableRowCountRef = useRef(tableData?.rowCount ?? 0) - tableRowCountRef.current = tableData?.rowCount ?? 0 + // True "select all" total: the filter-scoped COUNT(*) when a filter is active, else the whole + // table. Drives the delete-confirm count and the action-bar cell count. + const selectAllTotalRef = useRef(0) + selectAllTotalRef.current = rowTotal ?? tableData?.rowCount ?? 0 const fetchNextPageRef = useRef(fetchNextPage) fetchNextPageRef.current = fetchNextPage @@ -893,19 +938,14 @@ export function TableGrid({ const contextRowInRows = currentRows.some((r) => r.id === contextRow.id) - // Select-all delete covers every row matching the active filter, which may - // not all be loaded — drain pages first so the (chunked) delete spans the - // full set rather than only the loaded window. + // Select-all delete covers every row matching the active filter — including rows not loaded by + // the virtualized grid. Send the filter + exclusion set to a background job instead of draining + // and materializing every id; the wrapper confirms and kicks it off. if (rowSel.kind === 'all' && contextRowInRows) { closeContextMenu() - void (async () => { - const allRows = await ensureAllRowsLoadedRef.current() - const snapshots = collectRowSnapshots(allRows) - if (snapshots.length > 0) onRequestDeleteRows(snapshots) - })().catch((error) => { - logger.error('Failed to load rows for delete', { error }) - toast.error('Failed to delete rows — please try again') - }) + const excludeRowIds = rowSel.excluded ? [...rowSel.excluded] : [] + const estimatedCount = Math.max(0, selectAllTotalRef.current - excludeRowIds.length) + onRequestDeleteAllByFilter({ excludeRowIds, estimatedCount }) return } @@ -1138,6 +1178,15 @@ export function TableGrid({ : -1 setRowSelection((prev) => { + // Deselecting a single row out of a select-all keeps the "all matching the filter" semantics + // by tracking an exclusion set — collapsing to the loaded ids would silently drop every + // unloaded matching row from the selection (and from a subsequent delete). + if (prev.kind === 'all' && lastIdx === -1) { + const excluded = new Set(prev.excluded ?? []) + if (excluded.has(targetId)) excluded.delete(targetId) + else excluded.add(targetId) + return { kind: 'all', excluded: excluded.size === 0 ? undefined : excluded } + } const next = rowSelectionMaterialize(prev, currentRows) if (lastIdx !== -1) { const from = Math.min(lastIdx, rowIndex) @@ -1210,6 +1259,12 @@ export function TableGrid({ handleClearSelection() } + // Select-all delete runs as a background job (no client-side snapshots), so the only cleanup is + // clearing the now-stale selection once the wrapper has kicked it off. + afterDeleteAllSinkRef.current = () => { + handleClearSelection() + } + // Populate the wrapper's table-rename undo sink. The wrapper's // breadcrumb rename calls back here so the rename is part of the grid's undo // stack (Cmd-Z restores the previous name). @@ -2467,7 +2522,7 @@ export function TableGrid({ selectRow: (row) => rowSelectionIncludes(rowSel, row.id), buildCells: (row) => cols.map((col) => cellToText(row.data[col.key])), verb: 'Copied', - estimatedCount: rowSel.kind === 'some' ? rowSel.ids.size : tableRowCountRef.current, + estimatedCount: rowSel.kind === 'some' ? rowSel.ids.size : selectAllTotalRef.current, }) return } @@ -2491,7 +2546,7 @@ export function TableGrid({ selectRow: () => true, buildCells: (row) => colNames.map((name) => cellToText(row.data[name])), verb: 'Copied', - estimatedCount: tableRowCountRef.current, + estimatedCount: selectAllTotalRef.current, }) return } @@ -2529,7 +2584,7 @@ export function TableGrid({ selectRow: (row) => rowSelectionIncludes(rowSel, row.id), buildCells: (row) => cols.map((col) => cellToText(row.data[col.key])), verb: 'Cut', - estimatedCount: rowSel.kind === 'some' ? rowSel.ids.size : tableRowCountRef.current, + estimatedCount: rowSel.kind === 'some' ? rowSel.ids.size : selectAllTotalRef.current, afterCopy: (copied) => clearCutRows( copied, @@ -2558,7 +2613,7 @@ export function TableGrid({ selectRow: () => true, buildCells: (row) => colNames.map((name) => cellToText(row.data[name])), verb: 'Cut', - estimatedCount: tableRowCountRef.current, + estimatedCount: selectAllTotalRef.current, afterCopy: (copied) => clearCutRows(copied, colNames), }) return @@ -2731,18 +2786,15 @@ export function TableGrid({ const currentCols = columnsRef.current if (rws.length > 0 && currentCols.length > 0) { e.preventDefault() - suppressFocusScrollRef.current = true - setEditingCell(null) - setRowSelection((prev) => (prev.kind === 'none' ? prev : ROW_SELECTION_NONE)) - lastCheckboxRowRef.current = null - setSelectionAnchor({ rowIndex: 0, colIndex: 0 }) - setSelectionFocus({ rowIndex: rws.length - 1, colIndex: currentCols.length - 1 }) - setIsColumnSelection(false) + // Cmd/Ctrl+A toggles the whole-table row selection (same as the gutter checkbox), so it + // reflects the true row count and feeds the filter-based delete — not a loaded-window cell + // rectangle. Pressing again clears. + handleSelectAllToggle() } } document.addEventListener('keydown', handleSelectAll) return () => document.removeEventListener('keydown', handleSelectAll) - }, [embedded]) + }, [embedded, handleSelectAllToggle]) /** Override the browser's Cmd/Ctrl+F with the in-table find while mounted. */ useEffect(() => { @@ -3162,7 +3214,26 @@ export function TableGrid({ return [contextMenu.row.id] }, [contextMenu.isOpen, contextMenu.row, rowSelection, normalizedSelection, rows]) - const selectedRowCount = contextMenuRowIds.length || 1 + /** + * Select-all detection for the context-menu bulk actions: delete, run, + * refresh, and stop all act on EVERY row in the (filtered) selection — not + * just the loaded page `contextMenuRowIds` reflects — via the filter-scoped + * delete job / dispatch / cancel paths. + */ + const contextMenuIsSelectAll = Boolean( + contextMenu.isOpen && + contextMenu.row && + rowSelection.kind === 'all' && + rowSelectionIncludes(rowSelection, contextMenu.row.id) + ) + + const selectedRowCount = contextMenuIsSelectAll + ? Math.max( + 1, + selectAllTotalRef.current - + (rowSelection.kind === 'all' ? (rowSelection.excluded?.size ?? 0) : 0) + ) + : contextMenuRowIds.length || 1 const pendingUpdate = updateRowMutation.isPending ? updateRowMutation.variables : null @@ -3192,18 +3263,37 @@ export function TableGrid({ // opened on a workflow-output cell, scope to just that cell's group — the // server cascade re-runs dependent groups whose deps it fills. Right-clicking // a plain cell has no group, so fall back to every group on the row(s). - const handleRunWorkflowsOnSelection = () => { - if (contextMenuGroupId) onRunColumn(contextMenuGroupId, 'incomplete', contextMenuRowIds) - else onRunRows(contextMenuRowIds, 'incomplete') - closeContextMenu() - } - const handleRefreshWorkflowsOnSelection = () => { - if (contextMenuGroupId) onRunColumn(contextMenuGroupId, 'all', contextMenuRowIds) - else onRunRows(contextMenuRowIds, 'all') + /** Select-all runs dispatch by filter scope (whole table when unfiltered), + * mirroring the action bar's Play/Refresh; deselected rows are excluded. */ + const runSelection = (runMode: RunMode) => { + if (contextMenuIsSelectAll) { + const filter = queryOptions.filter ?? undefined + const excluded = + rowSelection.kind === 'all' && rowSelection.excluded + ? [...rowSelection.excluded] + : undefined + if (contextMenuGroupId) + onRunColumn(contextMenuGroupId, runMode, undefined, undefined, filter, excluded) + else onRunRows(undefined, runMode, filter, excluded) + } else if (contextMenuGroupId) { + onRunColumn(contextMenuGroupId, runMode, contextMenuRowIds) + } else { + onRunRows(contextMenuRowIds, runMode) + } closeContextMenu() } + const handleRunWorkflowsOnSelection = () => runSelection('incomplete') + const handleRefreshWorkflowsOnSelection = () => runSelection('all') const handleStopWorkflowsOnSelection = () => { - onStopRows(contextMenuRowIds) + if (contextMenuIsSelectAll) { + const excluded = + rowSelection.kind === 'all' && rowSelection.excluded + ? [...rowSelection.excluded] + : undefined + onStopAllRows(queryOptions.filter ?? undefined, excluded) + } else { + onStopRows(contextMenuRowIds) + } closeContextMenu() } @@ -3318,11 +3408,22 @@ export function TableGrid({ if (tableWorkflowGroupIds.length === 0) return null if (!rowSelectionIsEmpty(rowSelection)) { if (rowSelection.kind === 'all') { - return { groupIds: tableWorkflowGroupIds, rowIds: rows.map((r) => r.id), allRows: true } + // `rowIds` is the loaded window (virtualized); the label total comes from the filter-scoped + // row count minus any deselected rows. `filter` lets the run target the matching rows + // server-side (the dispatcher walks them) rather than the loaded window. + const excluded = rowSelection.excluded?.size ?? 0 + return { + groupIds: tableWorkflowGroupIds, + rowIds: rows.map((r) => r.id), + allRows: true, + rowCount: Math.max(0, selectAllTotalRef.current - excluded), + filter: queryOptions.filter ?? undefined, + excludeRowIds: rowSelection.excluded ? [...rowSelection.excluded] : undefined, + } } const rowIds = rows.filter((r) => rowSelectionIncludes(rowSelection, r.id)).map((r) => r.id) if (rowIds.length === 0) return null - return { groupIds: tableWorkflowGroupIds, rowIds, allRows: false } + return { groupIds: tableWorkflowGroupIds, rowIds, allRows: false, rowCount: rowIds.length } } const sel = normalizedSelection if (!sel) return null @@ -3340,7 +3441,7 @@ export function TableGrid({ if (row) rowIds.push(row.id) } if (rowIds.length === 0) return null - return { groupIds: [...groupIdsInRect], rowIds, allRows: false } + return { groupIds: [...groupIdsInRect], rowIds, allRows: false, rowCount: rowIds.length } }, [rowSelection, normalizedSelection, rows, displayColumns, tableWorkflowGroupIds]) const selectionStats = useMemo(() => { diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/utils.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/utils.ts index 7b263d4e1cf..39de6fb07dd 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/utils.ts +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/utils.ts @@ -11,13 +11,21 @@ import { areGroupDepsSatisfied, areOutputsFilled } from '@/lib/table/deps' import type { DeletedRowSnapshot } from '@/stores/table/types' import type { DisplayColumn } from './types' -export type RowSelection = { kind: 'none' } | { kind: 'some'; ids: Set } | { kind: 'all' } +/** + * `all` means "every row matching the active filter" — including rows not yet loaded by the + * virtualized grid. `excluded` holds rows deselected after a select-all, so the pair maps directly + * onto the async delete job's `{ filter, excludeRowIds }`. + */ +export type RowSelection = + | { kind: 'none' } + | { kind: 'some'; ids: Set } + | { kind: 'all'; excluded?: Set } export const ROW_SELECTION_NONE: RowSelection = { kind: 'none' } export const ROW_SELECTION_ALL: RowSelection = { kind: 'all' } export function rowSelectionIncludes(sel: RowSelection, id: string): boolean { - if (sel.kind === 'all') return true + if (sel.kind === 'all') return !sel.excluded?.has(id) if (sel.kind === 'some') return sel.ids.has(id) return false } @@ -29,14 +37,15 @@ export function rowSelectionIsEmpty(sel: RowSelection): boolean { } export function rowSelectionMaterialize(sel: RowSelection, rows: TableRowType[]): Set { - if (sel.kind === 'all') return new Set(rows.map((r) => r.id)) + if (sel.kind === 'all') + return new Set(rows.filter((r) => !sel.excluded?.has(r.id)).map((r) => r.id)) if (sel.kind === 'some') return new Set(sel.ids) return new Set() } export function rowSelectionCoversAll(sel: RowSelection, rows: TableRowType[]): boolean { if (rows.length === 0) return false - if (sel.kind === 'all') return true + if (sel.kind === 'all') return !rows.some((r) => sel.excluded?.has(r.id)) if (sel.kind === 'none') return false if (sel.ids.size < rows.length) return false for (const r of rows) if (!sel.ids.has(r.id)) return false diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/hooks/use-table-event-stream.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/hooks/use-table-event-stream.ts index 29cfbfd9478..34789dff546 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/hooks/use-table-event-stream.ts +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/hooks/use-table-event-stream.ts @@ -3,11 +3,18 @@ import { useEffect, useRef } from 'react' import { createLogger } from '@sim/logger' import { useQueryClient } from '@tanstack/react-query' +import { toast } from '@/components/emcn' import type { ActiveDispatch } from '@/lib/api/contracts/tables' import type { RowData, RowExecutionMetadata, RowExecutions, TableDefinition } from '@/lib/table' import { isExecInFlight } from '@/lib/table/deps' import type { TableEvent, TableEventEntry } from '@/lib/table/events' -import { snapshotAndMutateRows, type TableRunState, tableKeys } from '@/hooks/queries/tables' +import { + consumeInitiatedExport, + downloadExportResult, + snapshotAndMutateRows, + type TableRunState, + tableKeys, +} from '@/hooks/queries/tables' const logger = createLogger('useTableEventStream') @@ -94,11 +101,11 @@ export function useTableEventStream({ // Live-fill: import progress ticks arrive every N rows; coalesce the row // refetches into one per debounce window instead of refetching per tick. - let importInvalidateTimer: ReturnType | null = null + let jobInvalidateTimer: ReturnType | null = null const scheduleRowsInvalidate = (): void => { - if (importInvalidateTimer !== null) clearTimeout(importInvalidateTimer) - importInvalidateTimer = setTimeout(() => { - importInvalidateTimer = null + if (jobInvalidateTimer !== null) clearTimeout(jobInvalidateTimer) + jobInvalidateTimer = setTimeout(() => { + jobInvalidateTimer = null void queryClient.invalidateQueries({ queryKey: tableKeys.rowsRoot(tableId) }) }, DISPATCH_INVALIDATE_DEBOUNCE_MS) } @@ -224,37 +231,60 @@ export function useTableEventStream({ scheduleDispatchInvalidate() } - const applyImport = (event: Extract): void => { - const { status, progress, error, importId } = event + const applyJob = (event: Extract): void => { + const { type, status, progress, error, jobId } = event const isTerminal = status === 'ready' || status === 'failed' || status === 'canceled' - // The SSE buffer replays on (re)connect and can hold a *prior* import's events for this - // table. Ignore anything from a superseded run, and don't trust a replayed terminal before - // we know the active run's id. + // Exports run concurrently with other jobs and never touch the detail-cache job fields + // (those derive from the latest *non-export* job). Their only client effect: download the + // file when an export this session kicked off completes. The initiated-set guard is what + // keeps replayed `ready` events (SSE re-delivers up to 1h on reconnect) from re-downloading. + if (type === 'export') { + // Keep the tray's export list fresh between its polls. + void queryClient.invalidateQueries({ queryKey: tableKeys.exportJobs(workspaceId) }) + if (status === 'ready' && jobId && consumeInitiatedExport(jobId)) { + void downloadExportResult(workspaceId, tableId, jobId) + .then(() => toast.success('Export ready — downloading')) + .catch((err) => { + logger.error('Export download failed', { tableId, jobId, err }) + toast.error('Export finished but the download failed — try again from the table menu') + }) + } else if (status === 'failed' && jobId && consumeInitiatedExport(jobId)) { + toast.error(error || 'Export failed') + } + return + } + + // The SSE buffer replays on (re)connect and can hold a *prior* job's events for this table. + // Ignore anything from a superseded run, and don't trust a replayed terminal before we know + // the active run's id. const prev = queryClient.getQueryData(tableKeys.detail(tableId)) - const lockedId = prev?.importId - if (lockedId && importId && importId !== lockedId) return + const lockedId = prev?.jobId + if (lockedId && jobId && jobId !== lockedId) return if (!lockedId && isTerminal) return queryClient.setQueryData(tableKeys.detail(tableId), (p) => p ? { ...p, - importStatus: status, - importId: importId ?? p.importId, - importRowsProcessed: progress ?? p.importRowsProcessed, - importError: error ?? null, + jobStatus: status, + jobId: jobId ?? p.jobId, + jobType: type, + jobRowsProcessed: progress ?? p.jobRowsProcessed, + jobError: error ?? null, } : p ) - // The header tray + completion toast are owned by `useImportTrayPoll`. Here we only keep the - // detail cache + grid in sync: live-fill rows per batch (debounced), and on the terminal - // event refetch rows + the definition (the worker may have rewritten the schema). + // The header tray + completion toast are owned by the tray poll. Here we keep the detail + // cache + grid in sync. On terminal, refetch rows + the definition (import may have rewritten + // the schema; delete failure/cancel restores optimistically-hidden rows). While running, + // imports and backfills live-fill rows per batch; a delete has already optimistically removed + // its rows, so we don't refetch mid-run (that would flicker not-yet-deleted rows back in). if (isTerminal) { - if (importInvalidateTimer !== null) clearTimeout(importInvalidateTimer) + if (jobInvalidateTimer !== null) clearTimeout(jobInvalidateTimer) void queryClient.invalidateQueries({ queryKey: tableKeys.rowsRoot(tableId) }) void queryClient.invalidateQueries({ queryKey: tableKeys.detail(tableId) }) - } else { + } else if (type === 'import' || type === 'backfill') { scheduleRowsInvalidate() } } @@ -329,7 +359,7 @@ export function useTableEventStream({ savePointer(tableId, lastEventId) if (entry.event?.kind === 'cell') applyCell(entry.event) else if (entry.event?.kind === 'dispatch') applyDispatch(entry.event) - else if (entry.event?.kind === 'import') applyImport(entry.event) + else if (entry.event?.kind === 'job') applyJob(entry.event) else if (entry.event?.kind === 'usageLimitReached') applyUsageLimit(entry.event) } catch (err) { logger.warn('Failed to parse table event', { tableId, err }) @@ -364,7 +394,7 @@ export function useTableEventStream({ cancelled = true if (reconnectTimer !== null) clearTimeout(reconnectTimer) if (dispatchInvalidateTimer !== null) clearTimeout(dispatchInvalidateTimer) - if (importInvalidateTimer !== null) clearTimeout(importInvalidateTimer) + if (jobInvalidateTimer !== null) clearTimeout(jobInvalidateTimer) eventSource?.close() eventSource = null } diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/hooks/use-table.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/hooks/use-table.ts index 844c87a7c3c..818cdf43d20 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/hooks/use-table.ts +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/hooks/use-table.ts @@ -35,6 +35,8 @@ export interface UseTableReturn { isLoadingTable: boolean /** Flattened across every fetched infinite-query page. */ rows: TableRow[] + /** Filter-scoped total row count (server COUNT(*) for the active filter); null until loaded. */ + rowTotal: number | null isLoadingRows: boolean refetchRows: () => void /** @@ -96,6 +98,14 @@ export function useTable({ workspaceId, tableId, queryOptions }: UseTableParams) [rowsData?.pages] ) + // Server-side COUNT(*) for the active filter (page 0 only). Null until the first page lands; + // callers fall back to the table's unfiltered `rowCount`. This is the true "select all" total — + // it reflects the filter, unlike `tableData.rowCount`. + const rowTotal = useMemo( + () => rowsData?.pages[0]?.totalCount ?? null, + [rowsData?.pages] + ) + const refetchRows = useCallback(() => { void refetch() }, [refetch]) @@ -219,6 +229,7 @@ export function useTable({ workspaceId, tableId, queryOptions }: UseTableParams) tableData, isLoadingTable, rows, + rowTotal, isLoadingRows, refetchRows, fetchNextPage: fetchNextPageWrapped, diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx index 57a68a8485f..439f7b06672 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx @@ -10,6 +10,7 @@ import type { RunLimit, RunMode } from '@/lib/api/contracts/tables' import { captureEvent } from '@/lib/posthog/client' import type { ColumnDefinition, Filter, TableRow as TableRowType, WorkflowGroup } from '@/lib/table' import { getColumnId } from '@/lib/table/column-keys' +import { TABLE_LIMITS } from '@/lib/table/constants' import { type ColumnOption, Resource, @@ -24,6 +25,8 @@ import { downloadTableExport, useCancelTableRuns, useDeleteTable, + useDeleteTableRowsAsync, + useExportTableAsync, useRenameTable, useRunColumn, } from '@/hooks/queries/tables' @@ -137,6 +140,10 @@ export function Table({ const [isImportCsvOpen, setIsImportCsvOpen] = useState(false) const [editingRow, setEditingRow] = useState(null) const [deletingRows, setDeletingRows] = useState([]) + const [deletingAll, setDeletingAll] = useState<{ + excludeRowIds: string[] + estimatedCount: number + } | null>(null) const [deletingColumns, setDeletingColumns] = useState(null) const [selection, setSelection] = useState({ actionBarRowIds: [], @@ -179,6 +186,12 @@ export function Table({ const onRequestDeleteRows = useCallback((snapshots: DeletedRowSnapshot[]) => { setDeletingRows(snapshots) }, []) + const onRequestDeleteAllByFilter = useCallback( + (params: { excludeRowIds: string[]; estimatedCount: number }) => { + setDeletingAll(params) + }, + [] + ) const onRequestDeleteColumns = useCallback((names: string[]) => { setDeletingColumns(names) }, []) @@ -200,6 +213,9 @@ export function Table({ */ const afterDeleteRowsSinkRef = useRef<((snapshots: DeletedRowSnapshot[]) => void) | null>(null) + /** Sink the grid populates with its post-select-all-delete cleanup (clear selection). */ + const afterDeleteAllSinkRef = useRef<(() => void) | null>(null) + /** * Sink the grid populates with its full delete-columns cascade (per-column * mutation, undo push, columnOrder + columnWidths cleanup). The wrapper's @@ -236,6 +252,8 @@ export function Table({ (args: { groupIds: string[] rowIds?: string[] + filter?: Filter + excludeRowIds?: string[] runMode: RunMode limit?: RunLimit source: 'row' | 'rows' | 'column' @@ -268,15 +286,37 @@ export function Table({ ) const onRunColumn = useCallback( - (groupId: string, runMode: RunMode, rowIds?: string[], limit?: RunLimit) => { - runScope({ groupIds: [groupId], rowIds, runMode, limit, source: 'column' }) + ( + groupId: string, + runMode: RunMode, + rowIds?: string[], + limit?: RunLimit, + filter?: Filter, + excludeRowIds?: string[] + ) => { + runScope({ + groupIds: [groupId], + rowIds, + filter, + excludeRowIds, + runMode, + limit, + source: 'column', + }) }, [runScope] ) const onRunRows = useCallback( - (rowIds: string[], runMode: RunMode) => { - runScope({ groupIds: tableWorkflowGroups.map((g) => g.id), rowIds, runMode, source: 'rows' }) + (rowIds: string[] | undefined, runMode: RunMode, filter?: Filter, excludeRowIds?: string[]) => { + runScope({ + groupIds: tableWorkflowGroups.map((g) => g.id), + rowIds, + filter, + excludeRowIds, + runMode, + source: 'rows', + }) }, [runScope, tableWorkflowGroups] ) @@ -321,7 +361,9 @@ export function Table({ }) } - // useCallback because is memo-wrapped. + // useCallback because is memo-wrapped. Zero-arg on + // purpose — RunStatusControl passes it straight to onClick, which would + // otherwise leak the MouseEvent into `filter`. const onStopAll = useCallback(() => { cancelRunsMutate({ scope: 'all' }) captureEvent(posthogRef.current, 'table_workflow_stopped', { @@ -332,6 +374,20 @@ export function Table({ }) }, [cancelRunsMutate, tableId, workspaceId]) + /** Select-all Stop — filter-scoped when a filter is active; deselected rows keep running. */ + const onStopAllRows = useCallback( + (filter?: Filter, excludeRowIds?: string[]) => { + cancelRunsMutate({ scope: 'all', filter, excludeRowIds }) + captureEvent(posthogRef.current, 'table_workflow_stopped', { + table_id: tableId, + workspace_id: workspaceId, + scope: 'all', + row_count: null, + }) + }, + [cancelRunsMutate, tableId, workspaceId] + ) + const onSelectionChange = (next: SelectionSnapshot) => { setSelection(next) } @@ -371,7 +427,14 @@ export function Table({ const handleExportCsv = useCallback(async () => { if (!tableData) return try { - await downloadTableExport(tableData.id, tableData.name) + // Big tables export as a background job (the file downloads when the job completes via the + // SSE stream); small ones keep the instant synchronous stream. + if (tableData.rowCount > TABLE_LIMITS.EXPORT_ASYNC_THRESHOLD_ROWS) { + await exportTableAsync.mutateAsync({ format: 'csv' }) + toast.success('Export started — the download will begin when it finishes') + } else { + await downloadTableExport(tableData.id, tableData.name) + } captureEvent(posthogRef.current, 'table_exported', { table_id: tableData.id, workspace_id: workspaceId, @@ -499,6 +562,8 @@ export function Table({ : 0 const deleteTableMutation = useDeleteTable(workspaceId) + const deleteRowsAsyncMutation = useDeleteTableRowsAsync({ workspaceId, tableId }) + const exportTableAsync = useExportTableAsync({ workspaceId, tableId }) const handleDeleteTable = async () => { try { await deleteTableMutation.mutateAsync(tableId) @@ -595,16 +660,19 @@ export function Table({ onOpenExecutionDetails={onOpenExecutionDetails} onOpenRowModal={onOpenRowModal} onRequestDeleteRows={onRequestDeleteRows} + onRequestDeleteAllByFilter={onRequestDeleteAllByFilter} onRequestDeleteColumns={onRequestDeleteColumns} onRunColumn={onRunColumn} onRunRow={onRunRow} onRunRows={onRunRows} onStopRows={onStopRows} + onStopAllRows={onStopAllRows} onStopRow={onStopRow} onSelectionChange={onSelectionChange} queryOptions={queryOptions} columnRenameSinkRef={columnRenameSinkRef} afterDeleteRowsSinkRef={afterDeleteRowsSinkRef} + afterDeleteAllSinkRef={afterDeleteAllSinkRef} confirmDeleteColumnsSinkRef={confirmDeleteColumnsSinkRef} pushTableRenameUndoSinkRef={pushTableRenameUndoSinkRef} /> @@ -612,8 +680,7 @@ export function Table({ { const scope = selection.selectedRunScope if (!scope) return - scope.allRows ? onStopAll() : onStopRows(scope.rowIds) + if (scope.allRows) { + scope.filter || scope.excludeRowIds?.length + ? onStopAllRows(scope.filter, scope.excludeRowIds) + : onStopAll() + } else { + onStopRows(scope.rowIds) + } }} onViewExecution={ selection.singleWorkflowCell?.canViewExecution && @@ -722,6 +800,40 @@ export function Table({ }} /> )} + { + if (!open) setDeletingAll(null) + }} + srTitle='Delete rows' + title='Delete rows' + description={`Delete ${deletingAll ? deletingAll.estimatedCount.toLocaleString() : 0} ${ + deletingAll?.estimatedCount === 1 ? 'row' : 'rows' + }${queryOptions.filter ? ' matching the current filter' : ''}? This runs in the background and can't be undone.`} + confirm={{ + label: 'Delete', + pending: deleteRowsAsyncMutation.isPending, + pendingLabel: 'Deleting...', + onClick: () => { + if (!deletingAll) return + const { excludeRowIds } = deletingAll + deleteRowsAsyncMutation.mutate( + { + filter: queryOptions.filter ?? undefined, + sort: queryOptions.sort, + excludeRowIds: excludeRowIds.length > 0 ? excludeRowIds : undefined, + }, + { + // Clear the selection only once the kickoff succeeds — on + // failure the optimistic row clear rolls back and the user's + // selection should still be intact. + onSuccess: () => afterDeleteAllSinkRef.current?.(), + } + ) + setDeletingAll(null) + }, + }} + /> { diff --git a/apps/sim/app/workspace/[workspaceId]/tables/components/import-csv-dialog/import-csv-dialog.tsx b/apps/sim/app/workspace/[workspaceId]/tables/components/import-csv-dialog/import-csv-dialog.tsx index a718f010cc7..f042447a7a8 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/components/import-csv-dialog/import-csv-dialog.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/components/import-csv-dialog/import-csv-dialog.tsx @@ -28,7 +28,7 @@ import { buildAutoMapping, parseCsvBuffer } from '@/lib/table/import' import type { TableDefinition } from '@/lib/table/types' import { type CsvImportMode, - cancelTableImport, + cancelTableJob, useImportCsvIntoTable, useImportCsvIntoTableAsync, } from '@/hooks/queries/tables' @@ -322,7 +322,7 @@ export function ImportCsvDialog({ // the id so it's not shown and cancel the worker server-side. if (useImportTrayStore.getState().consumeCanceled(table.id) && data?.importId) { useImportTrayStore.getState().cancel(table.id) - void cancelTableImport(workspaceId, table.id, data.importId).catch(() => {}) + void cancelTableJob(workspaceId, table.id, data.importId).catch(() => {}) } }, onError: () => { diff --git a/apps/sim/app/workspace/[workspaceId]/tables/components/import-progress-menu/import-progress-menu.tsx b/apps/sim/app/workspace/[workspaceId]/tables/components/import-progress-menu/import-progress-menu.tsx index 1c1bf48fa48..238e3f0f7f4 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/components/import-progress-menu/import-progress-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/components/import-progress-menu/import-progress-menu.tsx @@ -1,18 +1,22 @@ 'use client' +import { createLogger } from '@sim/logger' import { Button, DropdownMenu, DropdownMenuContent, DropdownMenuTrigger, ProgressItem, + toast, } from '@/components/emcn' -import { Upload } from '@/components/emcn/icons' -import { cancelTableImport } from '@/hooks/queries/tables' +import { CircleAlert, CircleCheck, Loader } from '@/components/emcn/icons' +import { cancelTableJob, downloadExportResult } from '@/hooks/queries/tables' import { useImportTrayStore } from '@/stores/table/import-tray/store' import { getImportStage } from './import-stage' import { type ImportRow, useWorkspaceImports } from './use-workspace-imports' +const logger = createLogger('ImportProgressMenu') + interface ImportProgressMenuProps { workspaceId: string | undefined /** When mounted inside a specific table's header, the indicator is scoped to that table. */ @@ -20,13 +24,15 @@ interface ImportProgressMenuProps { } /** - * Header affordance for background CSV imports: a clickable `{done}/{total}` count that opens a - * dropdown of per-import progress rows. Renders nothing when there are no imports. The single - * import-progress surface for both the tables list and the in-table view. + * Header affordance for background table jobs: a clickable `{done}/{total}` count that opens a + * dropdown of per-job progress rows — CSV imports and exports (a ready export row carries a + * Download action). Renders nothing when there are no jobs. The single job-progress surface for + * both the tables list and the in-table view. */ export function ImportProgressMenu({ workspaceId, tableId }: ImportProgressMenuProps) { const imports = useWorkspaceImports(workspaceId, tableId) const dismiss = useImportTrayStore((state) => state.dismiss) + const dismissJob = useImportTrayStore((state) => state.dismissJob) const cancelId = useImportTrayStore((state) => state.cancel) const menuOpen = useImportTrayStore((state) => state.menuOpen) const setMenuOpen = useImportTrayStore((state) => state.setMenuOpen) @@ -35,21 +41,39 @@ export function ImportProgressMenu({ workspaceId, tableId }: ImportProgressMenuP const total = imports.length const done = imports.filter((e) => e.phase === 'ready').length + const anyRunning = imports.some((e) => e.phase === 'importing') + const anyFailed = imports.some((e) => e.phase === 'failed') const cancel = (row: ImportRow) => { cancelId(row.id) // Worker already running — cancel it server-side now. (An upload still mid-flight is canceled by - // the kickoff handler once its importId is known; see the `consumeCanceled` branches.) - if (row.importId) { - void cancelTableImport(row.workspaceId, row.id, row.importId).catch(() => {}) + // the kickoff handler once its jobId is known; see the `consumeCanceled` branches.) + if (row.jobId) { + void cancelTableJob(row.workspaceId, row.tableId, row.jobId).catch(() => {}) } } + const download = (row: ImportRow) => { + if (!row.jobId) return + void downloadExportResult(row.workspaceId, row.tableId, row.jobId).catch((err) => { + logger.error('Export download failed', { jobId: row.jobId, err }) + toast.error('Download failed — the export may have expired') + }) + } + return ( + ) : ( + stage.detail + ) + } onCancel={row.phase === 'importing' ? () => cancel(row) : undefined} - onDismiss={stage.dismissible ? () => dismiss(row.id) : undefined} + onDismiss={ + stage.dismissible + ? () => (row.jobType === 'export' ? dismissJob(row.id) : dismiss(row.id)) + : undefined + } /> ) })} diff --git a/apps/sim/app/workspace/[workspaceId]/tables/components/import-progress-menu/import-stage.ts b/apps/sim/app/workspace/[workspaceId]/tables/components/import-progress-menu/import-stage.ts index 56e0fb77739..8d20cfc2132 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/components/import-progress-menu/import-stage.ts +++ b/apps/sim/app/workspace/[workspaceId]/tables/components/import-progress-menu/import-stage.ts @@ -27,6 +27,32 @@ export function getImportStage(entry: ImportRow): ImportStageView { const name = entry.title const meta = typeof entry.percent === 'number' ? `${entry.percent}%` : undefined + if (entry.jobType === 'export') { + if (entry.phase === 'failed') { + return { + status: 'error', + title: `Export failed for ${name}`, + detail: entry.error ?? 'Something went wrong', + dismissible: true, + } + } + if (entry.phase === 'ready') { + // The menu replaces `detail` with a Download action for ready exports. + return { + status: 'success', + title: `Exported ${name}`, + detail: `${rows} rows`, + dismissible: true, + } + } + return { + status: 'pending', + title: `Exporting ${name}`, + detail: `${rows} rows`, + dismissible: false, + } + } + if (entry.phase === 'failed') { return { status: 'error', diff --git a/apps/sim/app/workspace/[workspaceId]/tables/components/import-progress-menu/use-workspace-imports.ts b/apps/sim/app/workspace/[workspaceId]/tables/components/import-progress-menu/use-workspace-imports.ts index a4f1acb25e0..e5227ab2665 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/components/import-progress-menu/use-workspace-imports.ts +++ b/apps/sim/app/workspace/[workspaceId]/tables/components/import-progress-menu/use-workspace-imports.ts @@ -3,7 +3,7 @@ import { useEffect, useMemo, useRef } from 'react' import { useShallow } from 'zustand/react/shallow' import { toast } from '@/components/emcn' -import { useTablesList } from '@/hooks/queries/tables' +import { useTablesList, useWorkspaceExportJobs } from '@/hooks/queries/tables' import { useImportTrayStore } from '@/stores/table/import-tray/store' const READY_AUTO_CLEAR_MS = 6000 @@ -11,25 +11,33 @@ const POLL_INTERVAL_MS = 2000 export type ImportPhase = 'importing' | 'ready' | 'failed' -/** A row rendered in the import tray. Importing rows come live from the table list; uploads are - * client-only until their server row exists. */ +/** A row rendered in the job tray. Import rows come live from the table list (uploads are + * client-only until their server row exists); export rows come from the workspace export-jobs + * query. Delete/backfill jobs are intentionally excluded — the grid reflects them directly. */ export interface ImportRow { + /** Table id for imports/uploads; job id for exports (a table can have several exports). */ id: string + /** The table the job belongs to — cancel and download need it for export rows. */ + tableId: string workspaceId: string title: string phase: ImportPhase + jobType: 'import' | 'export' rowsProcessed: number /** Upload byte percent (upload phase only). */ percent?: number error?: string - importId?: string + jobId?: string + /** Export rows: whether the generated file is downloadable. */ + hasResult?: boolean } /** * Single source for the import tray. Importing rows are derived live from the table list (polled - * while any import is in flight) rather than mirrored into a store; the store only supplies + * while any job is in flight) rather than mirrored into a store; the store only supplies * optimistic uploads and which terminal completions to surface this session. Also fires the - * completion toasts on the importing → terminal transition. + * completion toasts on the importing → terminal transition. Delete jobs never render as tray rows + * and only surface a toast on failure (a failed delete restores the optimistically-removed rows). */ export function useWorkspaceImports( workspaceId: string | undefined, @@ -37,8 +45,11 @@ export function useWorkspaceImports( ): ImportRow[] { const { data: tables } = useTablesList(workspaceId, 'active', { refetchInterval: (list) => - list?.some((t) => t.importStatus === 'importing') ? POLL_INTERVAL_MS : false, + list?.some((t) => t.jobStatus === 'running') ? POLL_INTERVAL_MS : false, }) + // Exports are excluded from the table-level job derivation (they run concurrently with other + // jobs), so the tray reads them from their dedicated workspace listing. + const { data: exportJobs } = useWorkspaceExportJobs(workspaceId) const prevStatus = useRef>(new Map()) useEffect(() => { @@ -46,17 +57,29 @@ export function useWorkspaceImports( const store = useImportTrayStore.getState() for (const table of tables) { const before = prevStatus.current.get(table.id) - const now = table.importStatus ?? 'none' - if (before === 'importing' && now === 'ready') { - const rows = (table.importRowsProcessed ?? 0).toLocaleString() - toast.success(`Imported ${rows} rows into "${table.name}"`) - store.notify(table.id) - setTimeout(() => useImportTrayStore.getState().dismiss(table.id), READY_AUTO_CLEAR_MS) - } else if (before === 'importing' && now === 'failed') { - toast.error(table.importError || `Import failed for "${table.name}"`) - store.notify(table.id) + const now = table.jobStatus ?? 'none' + if (before === 'running' && now === 'ready') { + // Success toast only for imports — deletes reflect instantly in the grid and backfills + // live-fill cells; announcing them would be noise. + if (table.jobType === 'import') { + const rows = (table.jobRowsProcessed ?? 0).toLocaleString() + toast.success(`Imported ${rows} rows into "${table.name}"`) + store.notify(table.id) + setTimeout(() => useImportTrayStore.getState().dismiss(table.id), READY_AUTO_CLEAR_MS) + } + } else if (before === 'running' && now === 'failed') { + // Surface every failure — e.g. a failed delete restores the optimistically-removed rows, + // and a failed backfill leaves cells unfilled; the user should know why. + const fallback = + table.jobType === 'delete' + ? `Delete failed for "${table.name}"` + : table.jobType === 'backfill' + ? `Column backfill failed for "${table.name}"` + : `Import failed for "${table.name}"` + toast.error(table.jobError || fallback) + if (table.jobType === 'import') store.notify(table.id) } - if (now !== 'importing' && store.isCanceled(table.id)) store.consumeCanceled(table.id) + if (now !== 'running' && store.isCanceled(table.id)) store.consumeCanceled(table.id) prevStatus.current.set(table.id, now) } }, [tables]) @@ -64,6 +87,7 @@ export function useWorkspaceImports( const uploads = useImportTrayStore(useShallow((s) => Object.values(s.uploads))) const notified = useImportTrayStore((s) => s.notified) const canceledIds = useImportTrayStore((s) => s.canceledIds) + const dismissedIds = useImportTrayStore((s) => s.dismissedIds) return useMemo(() => { const rows: ImportRow[] = [] @@ -71,28 +95,35 @@ export function useWorkspaceImports( for (const table of tables ?? []) { if (scopeTableId && table.id !== scopeTableId) continue - if (table.importStatus === 'importing') { + // Of the table-derived jobs, only imports render here: deletes reflect optimistically in + // the grid and backfills live-fill cells via SSE. (Exports merge in below.) + if (table.jobType !== 'import') continue + if (table.jobStatus === 'running') { if (canceledIds[table.id]) continue rows.push({ id: table.id, + tableId: table.id, workspaceId: table.workspaceId, title: table.name, phase: 'importing', - rowsProcessed: table.importRowsProcessed ?? 0, - importId: table.importId ?? undefined, + jobType: 'import', + rowsProcessed: table.jobRowsProcessed ?? 0, + jobId: table.jobId ?? undefined, }) seen.add(table.id) } else if ( - (table.importStatus === 'ready' || table.importStatus === 'failed') && + (table.jobStatus === 'ready' || table.jobStatus === 'failed') && notified[table.id] ) { rows.push({ id: table.id, + tableId: table.id, workspaceId: table.workspaceId, title: table.name, - phase: table.importStatus, - rowsProcessed: table.importRowsProcessed ?? 0, - error: table.importError ?? undefined, + phase: table.jobStatus, + jobType: 'import', + rowsProcessed: table.jobRowsProcessed ?? 0, + error: table.jobError ?? undefined, }) seen.add(table.id) } @@ -104,15 +135,50 @@ export function useWorkspaceImports( if (canceledIds[upload.uploadId] || seen.has(upload.uploadId)) continue rows.push({ id: upload.uploadId, + tableId: upload.uploadId, workspaceId: upload.workspaceId, title: upload.title, phase: 'importing', + jobType: 'import', rowsProcessed: 0, percent: upload.percent, }) } + // Export rows: running ones always; terminal ready stays listed (re-downloadable) until the + // server's visibility window lapses or the user dismisses it. Keyed by jobId. + for (const job of exportJobs ?? []) { + if (!workspaceId) break + if (scopeTableId && job.tableId !== scopeTableId) continue + if (job.status === 'canceled' || canceledIds[job.jobId] || dismissedIds[job.jobId]) continue + if (job.status === 'running') { + rows.push({ + id: job.jobId, + tableId: job.tableId, + workspaceId, + title: job.tableName, + phase: 'importing', + jobType: 'export', + rowsProcessed: job.rowsProcessed, + jobId: job.jobId, + }) + } else { + rows.push({ + id: job.jobId, + tableId: job.tableId, + workspaceId, + title: job.tableName, + phase: job.status === 'ready' ? 'ready' : 'failed', + jobType: 'export', + rowsProcessed: job.rowsProcessed, + jobId: job.jobId, + hasResult: job.hasResult, + error: job.error ?? undefined, + }) + } + } + rows.sort((a, b) => (a.phase === b.phase ? 0 : a.phase === 'importing' ? -1 : 1)) return rows - }, [tables, uploads, notified, canceledIds, workspaceId, scopeTableId]) + }, [tables, exportJobs, uploads, notified, canceledIds, dismissedIds, workspaceId, scopeTableId]) } diff --git a/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx b/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx index 676fd3b6724..8a0cc6db062 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx @@ -27,7 +27,7 @@ import { import { TableContextMenu } from '@/app/workspace/[workspaceId]/tables/components/table-context-menu' import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks' import { - cancelTableImport, + cancelTableJob, downloadTableExport, useCreateTable, useDeleteTable, @@ -448,7 +448,7 @@ export function Tables() { useImportTrayStore.getState().consumeCanceled(pendingId) ) { useImportTrayStore.getState().cancel(result.tableId) - void cancelTableImport(workspaceId, result.tableId, result.importId).catch(() => {}) + void cancelTableJob(workspaceId, result.tableId, result.importId).catch(() => {}) } } catch { // The hook's onError surfaces the toast; just clear the tray indicator here. diff --git a/apps/sim/background/table-backfill.ts b/apps/sim/background/table-backfill.ts new file mode 100644 index 00000000000..3c210b17e53 --- /dev/null +++ b/apps/sim/background/table-backfill.ts @@ -0,0 +1,21 @@ +import { task } from '@trigger.dev/sdk' +import { runTableBackfill, type TableBackfillPayload } from '@/lib/table/backfill-runner' + +/** + * Trigger.dev wrapper around `runTableBackfill` (output-column backfill from saved execution + * logs). Retry-safe: re-plucking the same trace spans writes the same values, and + * `overwrite: false` passes skip already-filled cells. The `table_jobs` ownership gate stops a + * run that lost the job within one page. + */ +export const tableBackfillTask = task({ + id: 'table-backfill', + machine: 'small-1x', + retry: { maxAttempts: 3 }, + queue: { + name: 'table-backfill', + concurrencyLimit: 10, + }, + run: async (payload: TableBackfillPayload) => { + await runTableBackfill(payload) + }, +}) diff --git a/apps/sim/background/table-delete.ts b/apps/sim/background/table-delete.ts new file mode 100644 index 00000000000..464e963fd49 --- /dev/null +++ b/apps/sim/background/table-delete.ts @@ -0,0 +1,29 @@ +import { task } from '@trigger.dev/sdk' +import { runTableDelete, type TableDeletePayload } from '@/lib/table/delete-runner' + +/** + * `TableDeletePayload` with the cutoff as an ISO string — task payloads cross a JSON boundary, so + * the Date is rehydrated in `run` rather than trusting payload serialization. + */ +export interface TableDeleteTaskPayload extends Omit { + cutoff: string +} + +/** + * Trigger.dev wrapper around `runTableDelete`. Retry-safe: the worker keysets by id with a + * `created_at <= cutoff` floor and pages are committed independently, so a retried attempt simply + * re-walks and deletes whatever remains. The `table_jobs` ownership gate stops a retried run that + * lost the job (canceled / janitor-failed) within one page. + */ +export const tableDeleteTask = task({ + id: 'table-delete', + machine: 'small-1x', + retry: { maxAttempts: 3 }, + queue: { + name: 'table-delete', + concurrencyLimit: 10, + }, + run: async (payload: TableDeleteTaskPayload) => { + await runTableDelete({ ...payload, cutoff: new Date(payload.cutoff) }) + }, +}) diff --git a/apps/sim/background/table-export.ts b/apps/sim/background/table-export.ts new file mode 100644 index 00000000000..49a9c622c3c --- /dev/null +++ b/apps/sim/background/table-export.ts @@ -0,0 +1,21 @@ +import { task } from '@trigger.dev/sdk' +import { runTableExport, type TableExportPayload } from '@/lib/table/export-runner' + +/** + * Trigger.dev wrapper around `runTableExport`. Retry-safe: a retried attempt regenerates the file + * from scratch (failures clean up their partial upload), and the `table_jobs` ownership gate + * stops a run that lost the job. `medium-1x` — the serialized file is buffered in memory before + * the single-shot storage upload (~hundreds of MB worst case for enterprise 1M-row tables). + */ +export const tableExportTask = task({ + id: 'table-export', + machine: 'medium-1x', + retry: { maxAttempts: 3 }, + queue: { + name: 'table-export', + concurrencyLimit: 10, + }, + run: async (payload: TableExportPayload) => { + await runTableExport(payload) + }, +}) diff --git a/apps/sim/background/table-import.ts b/apps/sim/background/table-import.ts new file mode 100644 index 00000000000..65f8fbb82f2 --- /dev/null +++ b/apps/sim/background/table-import.ts @@ -0,0 +1,24 @@ +import { task } from '@trigger.dev/sdk' +import { runTableImport, type TableImportPayload } from '@/lib/table/import-runner' + +/** + * Trigger.dev wrapper around `runTableImport`. The job's lifecycle (claim, progress heartbeat, + * cancel, terminal state) lives in the `table_jobs` state machine, so the task is a thin shell: + * the worker's per-batch ownership gate stops it on cancel/supersede regardless of where it runs. + * + * `maxAttempts: 1` — a blind re-run would re-insert batches the failed attempt already committed + * (imports commit per batch with no rollback). A crashed import marks failed via the worker's own + * catch, or the stale-job janitor if the process died; the user retries the upload. + */ +export const tableImportTask = task({ + id: 'table-import', + machine: 'small-1x', + retry: { maxAttempts: 1 }, + queue: { + name: 'table-import', + concurrencyLimit: 10, + }, + run: async (payload: TableImportPayload) => { + await runTableImport(payload) + }, +}) diff --git a/apps/sim/hooks/queries/tables.test.ts b/apps/sim/hooks/queries/tables.test.ts index 473c29fa4d9..da4c8c1cc04 100644 --- a/apps/sim/hooks/queries/tables.test.ts +++ b/apps/sim/hooks/queries/tables.test.ts @@ -348,20 +348,20 @@ describe('tableRowsParamsKey', () => { describe('tableRowsInfiniteOptions', () => { const PAGE_SIZE = 1000 - function makeOpts(pageSize = PAGE_SIZE) { + function makeOpts(pageSize = PAGE_SIZE, sort: unknown = null) { return tableRowsInfiniteOptions({ workspaceId: WORKSPACE_ID, tableId: TABLE_ID, pageSize, filter: null, - sort: null, + sort: sort as never, }) as { queryKey: readonly unknown[] getNextPageParam: ( lastPage: { rows: unknown[] }, allPages: unknown[], lastPageParam: unknown - ) => number | undefined + ) => number | { orderKey: string; id: string } | undefined } } @@ -393,6 +393,26 @@ describe('tableRowsInfiniteOptions', () => { expect(opts.getNextPageParam(lastPartialPage, [], 2000)).toBeUndefined() }) + it('getNextPageParam returns a keyset cursor when rows carry orderKey and there is no sort', () => { + const opts = makeOpts() + const fullPage = { + rows: Array.from({ length: PAGE_SIZE }, (_, i) => ({ id: `r${i}`, orderKey: `a${i}` })), + } + expect(opts.getNextPageParam(fullPage, [], 0)).toEqual({ + orderKey: `a${PAGE_SIZE - 1}`, + id: `r${PAGE_SIZE - 1}`, + }) + }) + + it('getNextPageParam falls back to offset for sorted views even with orderKey present', () => { + const opts = makeOpts(PAGE_SIZE, { column: 'name', direction: 'asc' }) + const fullPage = { + rows: Array.from({ length: PAGE_SIZE }, (_, i) => ({ id: `r${i}`, orderKey: `a${i}` })), + } + expect(opts.getNextPageParam(fullPage, [], 0)).toBe(PAGE_SIZE) + expect(opts.getNextPageParam(fullPage, [], PAGE_SIZE)).toBe(PAGE_SIZE * 2) + }) + it('queryKey includes the result of tableRowsParamsKey', () => { const paramsKey = tableRowsParamsKey({ pageSize: PAGE_SIZE, filter: null, sort: null }) const opts = makeOpts(PAGE_SIZE) diff --git a/apps/sim/hooks/queries/tables.ts b/apps/sim/hooks/queries/tables.ts index f854284e648..8c74faee5da 100644 --- a/apps/sim/hooks/queries/tables.ts +++ b/apps/sim/hooks/queries/tables.ts @@ -29,21 +29,26 @@ import { batchUpdateTableRowsContract, type CreateTableBodyInput, type CreateTableColumnBodyInput, - cancelTableImportContract, + cancelTableJobContract, cancelTableRunsContract, createTableContract, createTableRowContract, + type DeleteTableRowsAsyncBody, deleteTableColumnContract, deleteTableContract, deleteTableRowContract, + deleteTableRowsAsyncContract, deleteTableRowsContract, deleteWorkflowGroupContract, + exportDownloadContract, + exportTableAsyncContract, findTableRowsContract, getTableContract, type InsertTableRowBodyInput, importIntoTableAsyncContract, importTableAsyncContract, listActiveDispatchesContract, + listTableJobsContract, listTableRowsContract, listTablesContract, type RunLimit, @@ -53,6 +58,7 @@ import { runColumnContract, type TableFindMatch, type TableIdParamsInput, + type TableJobSummary, type TableRowParamsInput, type TableRowsQueryInput, type UpdateTableColumnBodyInput, @@ -73,6 +79,7 @@ import type { TableDefinition, TableMetadata, TableRow, + TableRowsCursor, WorkflowGroup, WorkflowGroupDependencies, WorkflowGroupOutput, @@ -97,6 +104,8 @@ export const tableKeys = { [...tableKeys.lists(), workspaceId ?? '', scope] as const, details: () => [...tableKeys.all, 'detail'] as const, detail: (tableId: string) => [...tableKeys.details(), tableId] as const, + exportJobs: (workspaceId?: string) => + [...tableKeys.all, 'export-jobs', workspaceId ?? ''] as const, rowsRoot: (tableId: string) => [...tableKeys.detail(tableId), 'rows'] as const, infiniteRows: (tableId: string, paramsKey: string) => [...tableKeys.rowsRoot(tableId), 'infinite', paramsKey] as const, @@ -113,6 +122,12 @@ type TableRowsParams = Omit & sort?: Sort | null } +/** + * Infinite-rows page param: a keyset cursor on the default `(order_key, id)` order, or a numeric + * offset for sorted views / legacy rows without an order key. `0` doubles as the first page. + */ +export type TableRowsPageParam = number | TableRowsCursor + export type TableRowsResponse = Pick< ContractJsonResponse['data'], 'rows' | 'totalCount' @@ -151,6 +166,7 @@ async function fetchTableRows({ tableId, limit, offset, + after, filter, sort, includeTotal, @@ -162,6 +178,7 @@ async function fetchTableRows({ workspaceId, limit, offset, + after, filter: filter ?? undefined, sort: sort ?? undefined, includeTotal, @@ -438,21 +455,31 @@ export function tableRowsInfiniteOptions({ const paramsKey = tableRowsParamsKey({ pageSize, filter, sort }) return infiniteQueryOptions({ queryKey: tableKeys.infiniteRows(tableId, paramsKey), - queryFn: ({ pageParam, signal }) => - fetchTableRows({ + queryFn: ({ pageParam, signal }) => { + const param = pageParam as TableRowsPageParam + return fetchTableRows({ workspaceId, tableId, limit: pageSize, - offset: pageParam as number, + ...(typeof param === 'number' ? { offset: param } : { after: param }), filter, sort, - includeTotal: pageParam === 0, + includeTotal: param === 0, signal, - }), - initialPageParam: 0, - getNextPageParam: (lastPage, _allPages, lastPageParam) => { + }) + }, + initialPageParam: 0 as TableRowsPageParam, + getNextPageParam: (lastPage, _allPages, lastPageParam): TableRowsPageParam | undefined => { if (lastPage.rows.length < pageSize) return undefined - return (lastPageParam as number) + pageSize + // Default order pages by keyset cursor — each page is an index seek on (order_key, id), + // where OFFSET would re-scan every prior row (O(N²) across a deep scroll / full drain). + // Sorted views (and legacy rows without an order key) fall back to offset paging. + if (!sort) { + const last = lastPage.rows[lastPage.rows.length - 1] + if (last?.orderKey) return { orderKey: last.orderKey, id: last.id } + } + const param = lastPageParam as TableRowsPageParam + return (typeof param === 'number' ? param : 0) + lastPage.rows.length }, staleTime: 30 * 1000, }) @@ -653,7 +680,7 @@ function patchCachedRows( tableId: string, patchRow: (row: TableRow) => TableRow ) { - queryClient.setQueriesData>( + queryClient.setQueriesData>( { queryKey: tableKeys.rowsRoot(tableId), exact: false }, (old) => { if (!old) return old @@ -700,7 +727,7 @@ function reconcileCreatedRow( tableId: string, row: TableRow ) { - queryClient.setQueriesData>( + queryClient.setQueriesData>( { queryKey: tableKeys.rowsRoot(tableId), exact: false, @@ -827,7 +854,9 @@ export function useUpdateTableRow({ workspaceId, tableId }: RowMutationContext) onMutate: async ({ rowId, data }) => { await queryClient.cancelQueries({ queryKey: tableKeys.rowsRoot(tableId) }) - const previousQueries = queryClient.getQueriesData>({ + const previousQueries = queryClient.getQueriesData< + InfiniteData + >({ queryKey: tableKeys.rowsRoot(tableId), }) @@ -914,7 +943,9 @@ export function useBatchUpdateTableRows({ workspaceId, tableId }: RowMutationCon onMutate: async ({ updates }) => { await queryClient.cancelQueries({ queryKey: tableKeys.rowsRoot(tableId) }) - const previousQueries = queryClient.getQueriesData>({ + const previousQueries = queryClient.getQueriesData< + InfiniteData + >({ queryKey: tableKeys.rowsRoot(tableId), }) @@ -1032,6 +1063,82 @@ export function useDeleteTableRows({ workspaceId, tableId }: RowMutationContext) }) } +interface DeleteTableRowsAsyncVariables { + /** Active filter; omit for a whole-table "select all". */ + filter?: DeleteTableRowsAsyncBody['filter'] + /** Active sort — together with `filter` it identifies the exact rows query to optimistically + * strip, so we don't clear unrelated cached views (other filters/sorts). */ + sort?: Sort | null + /** Rows deselected after "select all" — spared by the job. */ + excludeRowIds?: string[] +} + +/** + * Kicks off a background "select all" delete (filter + optional exclusion set) instead of sending + * every row id. Optimistically strips the rows from the *active* filter/sort view only (the one the + * user is looking at) so the table empties instantly while the worker deletes in the background; + * emptying that view's pages also drops `hasNextPage`, so scrolling won't reload not-yet-deleted + * rows. Other cached views are left intact. The SSE job stream reconciles on completion (and + * restores rows on failure/cancel). + */ +export function useDeleteTableRowsAsync({ workspaceId, tableId }: RowMutationContext) { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({ filter, excludeRowIds }: DeleteTableRowsAsyncVariables) => { + return requestJson(deleteTableRowsAsyncContract, { + params: { tableId }, + body: { workspaceId, filter, excludeRowIds }, + }) + }, + onMutate: async ({ filter, sort, excludeRowIds }) => { + // Target the exact infinite-rows query for the view the user is on — not every cached view. + const activeKey = tableKeys.infiniteRows( + tableId, + tableRowsParamsKey({ pageSize: TABLE_LIMITS.MAX_QUERY_LIMIT, filter: filter ?? null, sort }) + ) + await queryClient.cancelQueries({ queryKey: activeKey }) + const previousRows = + queryClient.getQueryData>(activeKey) + const previousDetail = queryClient.getQueryData(tableKeys.detail(tableId)) + const keep = new Set(excludeRowIds ?? []) + queryClient.setQueryData>( + activeKey, + (old) => + old + ? { + ...old, + pages: old.pages.map((page) => ({ + ...page, + rows: page.rows.filter((r) => keep.has(r.id)), + })), + } + : old + ) + return { activeKey, previousRows, previousDetail } + }, + onSuccess: ({ data }) => { + // Lock the SSE job consumer onto this run so its running/terminal events are accepted, and + // flip the list-driven tray into "deleting" without waiting for a poll. + queryClient.setQueryData(tableKeys.detail(tableId), (p) => + p ? { ...p, jobStatus: 'running', jobId: data.jobId, jobType: 'delete' } : p + ) + queryClient.invalidateQueries({ queryKey: tableKeys.lists() }) + }, + onError: (error, _vars, context) => { + // Restore the optimistically-removed rows — the kickoff failed, nothing was deleted. + if (context?.activeKey && context.previousRows) { + queryClient.setQueryData(context.activeKey, context.previousRows) + } + if (context?.previousDetail) { + queryClient.setQueryData(tableKeys.detail(tableId), context.previousDetail) + } + if (isValidationError(error)) return + toast.error(error.message, { duration: 5000 }) + }, + }) +} + type UpdateColumnParams = Omit /** @@ -1127,6 +1234,10 @@ export function useUpdateTableMetadata({ workspaceId, tableId }: RowMutationCont interface CancelRunsParams { scope: 'all' | 'row' rowId?: string + /** Scope-`all` only: cancel just the cells on rows matching this filter (filtered select-all Stop). */ + filter?: Filter + /** Scope-`all` only: deselected rows whose cells keep running. */ + excludeRowIds?: string[] } /** @@ -1141,15 +1252,18 @@ export function useCancelTableRuns({ workspaceId, tableId }: RowMutationContext) const queryClient = useQueryClient() return useMutation({ - mutationFn: async ({ scope, rowId }: CancelRunsParams) => { + mutationFn: async ({ scope, rowId, filter, excludeRowIds }: CancelRunsParams) => { return requestJson(cancelTableRunsContract, { params: { tableId }, - body: { workspaceId, scope, rowId }, + body: { workspaceId, scope, rowId, filter, excludeRowIds }, }) }, - onMutate: async ({ scope, rowId }) => { + onMutate: async ({ scope, rowId, excludeRowIds }) => { + const excludedRowIds = + excludeRowIds && excludeRowIds.length > 0 ? new Set(excludeRowIds) : null const snapshots = await snapshotAndMutateRows(queryClient, tableId, (r) => { if (scope === 'row' && r.id !== rowId) return null + if (excludedRowIds?.has(r.id)) return null const executions = (r.executions ?? {}) as RowExecutions let rowTouched = false const nextExecutions: RowExecutions = { ...executions } @@ -1443,18 +1557,103 @@ export function useImportCsvIntoTable() { * `/api/table/[tableId]/export`. Defaults to CSV; pass `'json'` for JSON. */ /** - * Cancels an in-flight async import. Plain function (not a hook) because the import dropdown lists - * multiple tables and cancels a chosen one by id rather than binding to a single table. + * Cancels an in-flight async table job (import or delete). Plain function (not a hook) because the + * job tray lists multiple tables and cancels a chosen one by id rather than binding to a single + * table. + */ +export async function cancelTableJob( + workspaceId: string, + tableId: string, + jobId: string +): Promise { + await requestJson(cancelTableJobContract, { + params: { tableId }, + body: { workspaceId, jobId }, + }) +} + +async function fetchWorkspaceExportJobs( + workspaceId: string, + signal?: AbortSignal +): Promise { + const response = await requestJson(listTableJobsContract, { + query: { workspaceId, type: 'export' }, + signal, + }) + return response.data.jobs +} + +/** + * Export jobs for the header tray: running ones plus recent terminals (re-downloadable). Polls + * while any export is in flight; otherwise the SSE job stream invalidates this key on export + * events, so the list stays fresh without a steady poll. + */ +export function useWorkspaceExportJobs(workspaceId?: string) { + return useQuery({ + queryKey: tableKeys.exportJobs(workspaceId), + queryFn: ({ signal }) => fetchWorkspaceExportJobs(workspaceId as string, signal), + enabled: Boolean(workspaceId), + staleTime: 5 * 1000, + refetchInterval: (query) => + query.state.data?.some((j) => j.status === 'running') ? 2000 : false, + }) +} + +/** + * Export jobs this session kicked off. The SSE buffer replays up to an hour of events on every + * (re)connect, so the job stream consumer must only auto-download `ready` events for exports the + * user just initiated — not replayed ones from a previous visit. + */ +const initiatedExportJobIds = new Set() + +/** Consumes (one-shot) whether this session initiated the export job. */ +export function consumeInitiatedExport(jobId: string): boolean { + return initiatedExportJobIds.delete(jobId) +} + +/** + * Kicks off a background export job for large tables (small ones stream synchronously via + * {@link downloadTableExport}). The SSE job stream auto-downloads the file when the job is ready. */ -export async function cancelTableImport( +export function useExportTableAsync({ workspaceId, tableId }: RowMutationContext) { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: async ({ format }: { format: 'csv' | 'json' }) => { + const response = await requestJson(exportTableAsyncContract, { + params: { tableId }, + body: { workspaceId, format }, + }) + initiatedExportJobIds.add(response.data.jobId) + return response.data + }, + onSuccess: () => { + // Surface the new running job in the tray immediately — its poll only + // self-sustains once a running job is already in the cache. + void queryClient.invalidateQueries({ queryKey: tableKeys.exportJobs(workspaceId) }) + }, + onError: (error) => { + if (isValidationError(error)) return + toast.error(error.message, { duration: 5000 }) + }, + }) +} + +/** Resolves a ready export job to its presigned URL and triggers the browser download. */ +export async function downloadExportResult( workspaceId: string, tableId: string, - importId: string + jobId: string ): Promise { - await requestJson(cancelTableImportContract, { + const response = await requestJson(exportDownloadContract, { params: { tableId }, - body: { workspaceId, importId }, + query: { workspaceId, jobId }, }) + const a = document.createElement('a') + a.href = response.data.url + a.download = response.data.fileName + document.body.appendChild(a) + a.click() + document.body.removeChild(a) } export async function downloadTableExport( @@ -1556,13 +1755,19 @@ interface RunColumnVariables { runMode?: RunMode /** Restrict to these rows. Server applies the same eligibility predicate. */ rowIds?: string[] + /** "Select all under a filter" — run every row matching this filter (mutually exclusive with + * `rowIds`). Optimistic stamping is skipped (like `limit`) since the matching set isn't known + * client-side; the dispatcher's real pending stamps drive the UI. */ + filter?: Filter + /** Select-all scope only: deselected rows — skipped by the dispatcher and the optimistic stamp. */ + excludeRowIds?: string[] /** Cap the run to the first `max` eligible rows. Omit for an unbounded run. * Optimistic stamping is skipped when set — the dispatcher's real pending * stamps drive the UI for the actual capped rows. */ limit?: RunLimit } -type InfiniteRowsCache = { pages: TableRowsResponse[]; pageParams: number[] } +type InfiniteRowsCache = { pages: TableRowsResponse[]; pageParams: TableRowsPageParam[] } /** * Cache shapes that hold table-row data. Single-page (`useTableRows`) and * infinite (`useInfiniteTableRows`) live under the same `rowsRoot(tableId)` @@ -1681,7 +1886,14 @@ export function useRunColumn({ workspaceId, tableId }: RowMutationContext) { const queryClient = useQueryClient() return useMutation({ - mutationFn: async ({ groupIds, runMode = 'all', rowIds, limit }: RunColumnVariables) => { + mutationFn: async ({ + groupIds, + runMode = 'all', + rowIds, + filter, + excludeRowIds, + limit, + }: RunColumnVariables) => { return requestJson(runColumnContract, { params: { tableId }, body: { @@ -1689,18 +1901,22 @@ export function useRunColumn({ workspaceId, tableId }: RowMutationContext) { groupIds, runMode, ...(rowIds && rowIds.length > 0 ? { rowIds } : {}), + ...(filter ? { filter } : {}), + ...(excludeRowIds && excludeRowIds.length > 0 ? { excludeRowIds } : {}), ...(limit ? { limit } : {}), }, }) }, - onMutate: async ({ groupIds, runMode = 'all', rowIds, limit }) => { - // Capped runs touch only the first N eligible rows, chosen server-side by - // position. We can't predict that set client-side, so optimistic stamping - // is skipped — the dispatcher's real pending stamps (cell SSE) drive the - // UI within the first window. - if (limit) + onMutate: async ({ groupIds, runMode = 'all', rowIds, filter, excludeRowIds, limit }) => { + // Capped and filtered runs target a set we can't predict client-side (capped picks the first + // N by position; filtered matches a server-evaluated predicate), so optimistic stamping is + // skipped — the dispatcher's real pending stamps (cell SSE) drive the UI within the first + // window. + if (limit || filter) return { snapshots: undefined, runStateSnapshot: undefined, didBumpRunState: false } const targetRowIds = rowIds && rowIds.length > 0 ? new Set(rowIds) : null + const excludedRowIds = + excludeRowIds && excludeRowIds.length > 0 ? new Set(excludeRowIds) : null const targetGroupIds = new Set(groupIds) const groups = queryClient.getQueryData(tableKeys.detail(tableId))?.schema @@ -1710,6 +1926,7 @@ export function useRunColumn({ workspaceId, tableId }: RowMutationContext) { const stampedByRow: Record = {} const snapshots = await snapshotAndMutateRows(queryClient, tableId, (r) => { if (targetRowIds && !targetRowIds.has(r.id)) return null + if (excludedRowIds?.has(r.id)) return null const executions = r.executions ?? {} let stamped = 0 const next: RowExecutions = { ...executions } diff --git a/apps/sim/lib/api/contracts/tables.ts b/apps/sim/lib/api/contracts/tables.ts index 0b277049000..8c0152ad9fe 100644 --- a/apps/sim/lib/api/contracts/tables.ts +++ b/apps/sim/lib/api/contracts/tables.ts @@ -8,6 +8,7 @@ import type { TableDefinition, TableMetadata, TableRow, + TableRowsCursor, } from '@/lib/table' import { COLUMN_TYPES, NAME_PATTERN, TABLE_LIMITS } from '@/lib/table/constants' import { CSV_MAX_FILE_SIZE_BYTES } from '@/lib/table/import' @@ -293,10 +294,17 @@ export const deleteTableRowsBodySchema = z message: 'Provide either filter or rowIds, but not both', }) -export const tableRowsQuerySchema = z.object({ +/** Unrefined base so v1 contracts can `.extend()` — consumers use {@link tableRowsQuerySchema}. */ +export const tableRowsQueryBaseSchema = z.object({ workspaceId: z.string().min(1, 'Workspace ID is required'), filter: domainObjectSchema().optional(), sort: domainObjectSchema().optional(), + /** + * Keyset cursor `(orderKey, id)` for the default row order — each page is an index seek + * instead of OFFSET's scan-and-discard. Mutually exclusive with `sort` (cursors only make + * sense on the default order); takes precedence over `offset`. + */ + after: domainObjectSchema().optional(), limit: z .preprocess( (value) => @@ -329,6 +337,11 @@ export const tableRowsQuerySchema = z.object({ .default(true), }) +export const tableRowsQuerySchema = tableRowsQueryBaseSchema.refine( + (data) => !(data.after && data.sort), + { message: 'after cursor cannot be combined with sort — cursors paginate the default order' } +) + export const updateRowsByFilterBodySchema = z.object({ workspaceId: z.string().min(1, 'Workspace ID is required'), filter: nonEmptyFilterSchema, @@ -724,6 +737,79 @@ export const tableExportFormatSchema = z ) .default('csv') +export const exportTableAsyncBodySchema = z.object({ + workspaceId: z.string().min(1, 'Workspace ID is required'), + format: z.enum(['csv', 'json']).default('csv'), +}) + +export type ExportTableAsyncBody = z.input + +/** + * Kickoff for a background export (large tables — small ones use the synchronous streaming + * `/export` route). The worker generates the file, uploads it to workspace storage, and the + * client fetches a presigned URL from the download contract once the job is `ready`. + */ +export const exportTableAsyncContract = defineRouteContract({ + method: 'POST', + path: '/api/table/[tableId]/export-async', + params: tableIdParamsSchema, + body: exportTableAsyncBodySchema, + response: { + mode: 'json', + schema: successResponseSchema(z.object({ tableId: z.string(), jobId: z.string() })), + }, +}) + +export const tableJobSummarySchema = z.object({ + jobId: z.string(), + tableId: z.string(), + tableName: z.string(), + status: z.enum(['running', 'ready', 'failed', 'canceled']), + rowsProcessed: z.number(), + format: z.enum(['csv', 'json']), + hasResult: z.boolean(), + error: z.string().nullable(), +}) + +export type TableJobSummary = z.output + +export const listTableJobsQuerySchema = z.object({ + workspaceId: z.string().min(1, 'Workspace ID is required'), + type: z.literal('export'), +}) + +/** + * Workspace-scoped job listing the header tray polls. Export-only today: exports are excluded + * from the table-level job derivation (they run concurrently with other jobs), so this is their + * dedicated read path — running jobs plus recently-finished ones for re-download. + */ +export const listTableJobsContract = defineRouteContract({ + method: 'GET', + path: '/api/table/jobs', + query: listTableJobsQuerySchema, + response: { + mode: 'json', + schema: successResponseSchema(z.object({ jobs: z.array(tableJobSummarySchema) })), + }, +}) + +export const exportDownloadQuerySchema = z.object({ + workspaceId: z.string().min(1, 'Workspace ID is required'), + jobId: z.string().min(1, 'Job ID is required'), +}) + +/** Resolves a completed export job to a short-lived presigned download URL. */ +export const exportDownloadContract = defineRouteContract({ + method: 'GET', + path: '/api/table/[tableId]/export/download', + params: tableIdParamsSchema, + query: exportDownloadQuerySchema, + response: { + mode: 'json', + schema: successResponseSchema(z.object({ url: z.string().min(1), fileName: z.string() })), + }, +}) + /** * `mapping` form field — a JSON-encoded `CsvHeaderMapping` (CSV header → * column name, or `null` to skip the header). @@ -834,6 +920,36 @@ export const deleteTableRowsContract = defineRouteContract({ }, }) +/** + * Kickoff body for an asynchronous "select all" delete. Sends the active filter (and an optional + * exclusion set for "select all then deselect a few") instead of every row id, so the background + * worker deletes in paginated batches. Omitting `filter` deletes the whole table (at the cutoff). + */ +export const deleteTableRowsAsyncBodySchema = z.object({ + workspaceId: z.string().min(1, 'Workspace ID is required'), + filter: nonEmptyFilterSchema.optional(), + excludeRowIds: z + .array(z.string().min(1)) + .max( + TABLE_LIMITS.MAX_EXCLUDE_ROW_IDS, + `Cannot exclude more than ${TABLE_LIMITS.MAX_EXCLUDE_ROW_IDS} rows` + ) + .optional(), +}) + +export type DeleteTableRowsAsyncBody = z.input + +export const deleteTableRowsAsyncContract = defineRouteContract({ + method: 'POST', + path: '/api/table/[tableId]/delete-async', + params: tableIdParamsSchema, + body: deleteTableRowsAsyncBodySchema, + response: { + mode: 'json', + schema: successResponseSchema(z.object({ tableId: z.string(), jobId: z.string() })), + }, +}) + // ============================================================================ // Workflow group contracts (`/api/table/[tableId]/groups`, `/cancel-runs`, // `/columns/run`, `/rows/run`, `/rows/[rowId]/cells/[groupId]/run`) @@ -993,7 +1109,8 @@ export const deleteWorkflowGroupContract = defineRouteContract({ /** * Cancel scopes: - * - `all` — every running/pending cell in the table + * - `all` — every running/pending cell in the table; with `filter`, only + * cells on rows matching it (filtered "select all" Stop) * - `row` — every running/pending cell for a specific row (`rowId` required) */ export const cancelTableRunsBodySchema = z @@ -1001,6 +1118,15 @@ export const cancelTableRunsBodySchema = z workspaceId: z.string().min(1, 'Workspace ID is required'), scope: z.enum(['all', 'row']), rowId: z.string().min(1).optional(), + filter: domainObjectSchema().optional(), + /** Scope-`all` only: rows deselected from the selection — their cells keep running. */ + excludeRowIds: z + .array(z.string().min(1)) + .max( + TABLE_LIMITS.MAX_EXCLUDE_ROW_IDS, + `Cannot exclude more than ${TABLE_LIMITS.MAX_EXCLUDE_ROW_IDS} rows` + ) + .optional(), }) .superRefine((value, ctx) => { if (value.scope === 'row' && !value.rowId) { @@ -1010,6 +1136,20 @@ export const cancelTableRunsBodySchema = z message: 'rowId is required when scope is "row"', }) } + if (value.scope === 'row' && value.filter) { + ctx.addIssue({ + code: 'custom', + path: ['filter'], + message: 'filter only applies to scope "all"', + }) + } + if (value.scope === 'row' && value.excludeRowIds) { + ctx.addIssue({ + code: 'custom', + path: ['excludeRowIds'], + message: 'excludeRowIds only applies to scope "all"', + }) + } }) export const cancelTableRunsContract = defineRouteContract({ @@ -1023,23 +1163,26 @@ export const cancelTableRunsContract = defineRouteContract({ }, }) -export const cancelTableImportBodySchema = z.object({ +export const cancelTableJobBodySchema = z.object({ workspaceId: z.string().min(1, 'Workspace ID is required'), - importId: z.string().min(1, 'Import ID is required'), + jobId: z.string().min(1, 'Job ID is required'), }) -/** Cancel an in-flight async CSV import. The worker stops; committed rows are left in place. */ -export const cancelTableImportContract = defineRouteContract({ +/** + * Cancel an in-flight async table job (import or delete). The worker stops at its next ownership + * check; committed work (inserted/deleted rows) is left in place. + */ +export const cancelTableJobContract = defineRouteContract({ method: 'POST', - path: '/api/table/[tableId]/import/cancel', + path: '/api/table/[tableId]/job/cancel', params: tableIdParamsSchema, - body: cancelTableImportBodySchema, + body: cancelTableJobBodySchema, response: { mode: 'json', schema: successResponseSchema(z.object({ canceled: z.boolean() })), }, }) -export type CancelTableImportBody = z.input +export type CancelTableJobBody = z.input /** * Run modes for `POST /api/table/[tableId]/columns/run`: @@ -1071,14 +1214,32 @@ export const runLimitSchema = z.object({ .max(1_000_000, 'max cannot exceed 1,000,000'), }) -export const runColumnBodySchema = z.object({ - workspaceId: z.string().min(1, 'Workspace ID is required'), - groupIds: z.array(z.string().min(1)).min(1), - runMode: z.enum(['all', 'incomplete']).default('all'), - rowIds: z.array(z.string().min(1)).min(1).optional(), - /** Cap the run to the first `max` eligible rows. Omit for an unbounded run. */ - limit: runLimitSchema.optional(), -}) +export const runColumnBodySchema = z + .object({ + workspaceId: z.string().min(1, 'Workspace ID is required'), + groupIds: z.array(z.string().min(1)).min(1), + runMode: z.enum(['all', 'incomplete']).default('all'), + rowIds: z.array(z.string().min(1)).min(1).optional(), + /** "Select all under a filter" — run every row matching this filter instead of `rowIds`. The + * dispatcher walks only matching rows (paginated), so no id list is materialized. */ + filter: nonEmptyFilterSchema.optional(), + /** Select-all scope only: rows deselected from the selection — the dispatcher skips them. */ + excludeRowIds: z + .array(z.string().min(1)) + .max( + TABLE_LIMITS.MAX_EXCLUDE_ROW_IDS, + `Cannot exclude more than ${TABLE_LIMITS.MAX_EXCLUDE_ROW_IDS} rows` + ) + .optional(), + /** Cap the run to the first `max` eligible rows. Omit for an unbounded run. */ + limit: runLimitSchema.optional(), + }) + .refine((data) => !(data.rowIds && data.filter), { + message: 'Provide either filter or rowIds, but not both', + }) + .refine((data) => !(data.rowIds && data.excludeRowIds), { + message: 'excludeRowIds only applies to select-all scope (no rowIds)', + }) export const runColumnContract = defineRouteContract({ method: 'POST', diff --git a/apps/sim/lib/api/contracts/v1/tables/index.ts b/apps/sim/lib/api/contracts/v1/tables/index.ts index 78b642e42dc..8beeec19a8a 100644 --- a/apps/sim/lib/api/contracts/v1/tables/index.ts +++ b/apps/sim/lib/api/contracts/v1/tables/index.ts @@ -9,7 +9,7 @@ import { rowDataSchema, tableIdParamsSchema, tableRowParamsSchema, - tableRowsQuerySchema, + tableRowsQueryBaseSchema, updateRowsByFilterBodySchema, updateTableColumnBodySchema, updateTableRowBodySchema, @@ -44,7 +44,7 @@ const optionalJsonObjectQuerySchema = (label: string) => return z.NEVER }) -export const v1TableRowsQuerySchema = tableRowsQuerySchema.extend({ +export const v1TableRowsQuerySchema = tableRowsQueryBaseSchema.omit({ after: true }).extend({ filter: optionalJsonObjectQuerySchema('filter'), sort: optionalJsonObjectQuerySchema('sort'), }) diff --git a/apps/sim/lib/copilot/chat/process-contents.test.ts b/apps/sim/lib/copilot/chat/process-contents.test.ts index c55f0e83a90..37aeaa2735c 100644 --- a/apps/sim/lib/copilot/chat/process-contents.test.ts +++ b/apps/sim/lib/copilot/chat/process-contents.test.ts @@ -7,8 +7,6 @@ import type { ChatContext } from '@/stores/panel' const { getSkillById } = vi.hoisted(() => ({ getSkillById: vi.fn() })) -vi.mock('@sim/db', () => ({ db: {} })) -vi.mock('@sim/db/schema', () => ({ document: {}, knowledgeBase: {} })) vi.mock('@/lib/workflows/skills/operations', () => ({ getSkillById })) import { processContextsServer } from './process-contents' diff --git a/apps/sim/lib/table/__tests__/find-row-matches.test.ts b/apps/sim/lib/table/__tests__/find-row-matches.test.ts index d2857eb71c3..cbc1276888e 100644 --- a/apps/sim/lib/table/__tests__/find-row-matches.test.ts +++ b/apps/sim/lib/table/__tests__/find-row-matches.test.ts @@ -74,7 +74,7 @@ describe('findRowMatches', () => { }) it('maps rows to matches, coercing the bigint ordinal and renaming the column', async () => { - dbChainMockFns.execute.mockResolvedValueOnce([ + dbChainMockFns.execute.mockResolvedValue([ { ordinal: '2', id: 'r2', column_name: 'name' }, { ordinal: 5, id: 'r5', column_name: 'email' }, ]) @@ -92,14 +92,14 @@ describe('findRowMatches', () => { id: `r${i}`, column_name: 'name', })) - dbChainMockFns.execute.mockResolvedValueOnce(over) + dbChainMockFns.execute.mockResolvedValue(over) const result = await findRowMatches(TABLE, { q: 'a' }, 'req') expect(result.truncated).toBe(true) expect(result.matches).toHaveLength(1000) }) it('threads filter and sort through the SQL builders', async () => { - dbChainMockFns.execute.mockResolvedValueOnce([]) + dbChainMockFns.execute.mockResolvedValue([]) await findRowMatches( TABLE, { q: 'a', filter: { name: { $contains: 'a' } }, sort: { name: 'asc' } }, diff --git a/apps/sim/lib/table/backfill-runner.ts b/apps/sim/lib/table/backfill-runner.ts new file mode 100644 index 00000000000..cbaf6e640f2 --- /dev/null +++ b/apps/sim/lib/table/backfill-runner.ts @@ -0,0 +1,343 @@ +import { db } from '@sim/db' +import { tableRowExecutions, userTableRows, workflowExecutionLogs } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { and, asc, count, eq, gt, inArray } from 'drizzle-orm' +import { isTriggerDevEnabled } from '@/lib/core/config/feature-flags' +import { runDetached } from '@/lib/core/utils/background' +import { MATERIALIZE_CONCURRENCY, mapWithConcurrency } from '@/lib/core/utils/concurrency' +import { materializeExecutionData } from '@/lib/logs/execution/trace-store' +import { appendTableEvent } from '@/lib/table/events' +import { + batchUpdateRows, + getTableById, + markJobFailed, + markJobReady, + markTableJobRunning, + updateJobProgress, +} from '@/lib/table/service' +import type { + RowData, + TableBackfillJobPayload, + TableDefinition, + WorkflowGroupOutput, +} from '@/lib/table/types' +import { pluckByPath } from './pluck' + +const logger = createLogger('TableBackfillRunner') + +/** Completed-run count above which the backfill runs as a background job instead of inline. */ +const BACKFILL_ASYNC_THRESHOLD_ROWS = 500 + +/** Completed sidecar rows fetched (and their logs materialized) per page. */ +const BACKFILL_PAGE_SIZE = 200 + +/** Thrown when this worker loses the job (canceled / janitor-failed). */ +class JobSupersededError extends Error {} + +export interface TableBackfillPayload { + jobId: string + tableId: string + workspaceId: string + groupId: string + outputs: WorkflowGroupOutput[] + overwrite: boolean + /** User who triggered the schema change, for usage attribution on the row writes. */ + actorUserId?: string | null +} + +/** Minimal shape of a trace span we care about for backfill. */ +interface BackfillTraceSpan { + blockId?: string + output?: Record + children?: BackfillTraceSpan[] +} + +/** DFS the trace tree for the first span matching `blockId`. */ +function findSpanByBlockId( + spans: BackfillTraceSpan[] | undefined, + blockId: string +): BackfillTraceSpan | undefined { + if (!spans) return undefined + for (const span of spans) { + if (span.blockId === blockId) return span + const child = findSpanByBlockId(span.children, blockId) + if (child) return child + } + return undefined +} + +/** One keyset page of completed (rowId, executionId) pairs for the group, ordered by rowId. */ +async function selectCompletedExecPage( + tableId: string, + groupId: string, + afterRowId: string | undefined, + limit: number +): Promise> { + return db + .select({ + rowId: tableRowExecutions.rowId, + executionId: tableRowExecutions.executionId, + }) + .from(tableRowExecutions) + .where( + and( + eq(tableRowExecutions.tableId, tableId), + eq(tableRowExecutions.groupId, groupId), + eq(tableRowExecutions.status, 'completed'), + afterRowId ? gt(tableRowExecutions.rowId, afterRowId) : undefined + ) + ) + .orderBy(asc(tableRowExecutions.rowId)) + .limit(limit) +} + +/** + * Backfills one page of rows: pulls each target output's value out of the rows' saved trace + * spans (materialized from object storage with bounded concurrency) and writes it into row data. + * Returns the number of rows updated. + */ +async function processBackfillPage(opts: { + table: TableDefinition + outputs: WorkflowGroupOutput[] + overwrite: boolean + execs: Array<{ rowId: string; executionId: string | null }> + requestId: string + actorUserId?: string | null +}): Promise { + const { table, outputs, overwrite, execs, requestId, actorUserId } = opts + + const executionIdsByRow = new Map() + for (const e of execs) { + if (!e.executionId) continue + executionIdsByRow.set(e.rowId, e.executionId) + } + if (executionIdsByRow.size === 0) return 0 + + const rowRecords = await db + .select({ id: userTableRows.id, data: userTableRows.data }) + .from(userTableRows) + .where( + and( + eq(userTableRows.tableId, table.id), + inArray(userTableRows.id, Array.from(executionIdsByRow.keys())) + ) + ) + + const executionIds = Array.from(new Set(executionIdsByRow.values())) + const logs = await db + .select({ + executionId: workflowExecutionLogs.executionId, + workflowId: workflowExecutionLogs.workflowId, + workspaceId: workflowExecutionLogs.workspaceId, + executionData: workflowExecutionLogs.executionData, + }) + .from(workflowExecutionLogs) + .where(inArray(workflowExecutionLogs.executionId, executionIds)) + + const logByExecutionId = new Map() + // Heavy execution data may live in object storage; resolve pointers (bounded concurrency). + await mapWithConcurrency(logs, MATERIALIZE_CONCURRENCY, async (log) => { + const executionData = await materializeExecutionData( + log.executionData as Record | null, + { workspaceId: log.workspaceId, workflowId: log.workflowId, executionId: log.executionId } + ) + logByExecutionId.set( + log.executionId, + (executionData as { traceSpans?: BackfillTraceSpan[] }) ?? {} + ) + }) + + const updates: Array<{ rowId: string; data: RowData }> = [] + for (const r of rowRecords) { + const execId = executionIdsByRow.get(r.id) + if (!execId) continue + const log = logByExecutionId.get(execId) + if (!log) continue + + const dataPatch: RowData = {} + let mutated = false + for (const out of outputs) { + if (!overwrite && (r.data as RowData)[out.columnName] !== undefined) continue + const span = findSpanByBlockId(log.traceSpans, out.blockId) + if (!span?.output) continue + const picked = pluckByPath(span.output, out.path) + if (picked === undefined) continue + dataPatch[out.columnName] = picked as RowData[string] + mutated = true + } + if (!mutated) continue + updates.push({ rowId: r.id, data: dataPatch }) + } + + if (updates.length === 0) return 0 + + await batchUpdateRows( + { tableId: table.id, updates, workspaceId: table.workspaceId, actorUserId }, + table, + requestId + ) + return updates.length +} + +/** + * Background worker for large output-column backfills. Pages the group's completed executions + * (keyset by rowId), materializing logs and writing values page by page. Ownership-gated per + * page; retry-safe (re-plucking the same spans writes the same values, and `overwrite: false` + * passes skip already-filled cells). + */ +export async function runTableBackfill(payload: TableBackfillPayload): Promise { + const { jobId, tableId, groupId, outputs, overwrite, actorUserId } = payload + const requestId = generateId().slice(0, 8) + + try { + const table = await getTableById(tableId, { includeArchived: true }) + if (!table) throw new Error(`Backfill target table ${tableId} not found`) + + let processed = 0 + let updated = 0 + let afterRowId: string | undefined + + while (true) { + const owns = await updateJobProgress(tableId, processed, jobId) + if (!owns) throw new JobSupersededError() + + const execs = await selectCompletedExecPage(tableId, groupId, afterRowId, BACKFILL_PAGE_SIZE) + if (execs.length === 0) break + afterRowId = execs[execs.length - 1].rowId + + updated += await processBackfillPage({ + table, + outputs, + overwrite, + execs, + requestId, + actorUserId, + }) + processed += execs.length + } + + await updateJobProgress(tableId, processed, jobId) + const becameReady = await markJobReady(tableId, jobId) + if (becameReady) { + void appendTableEvent({ + kind: 'job', + type: 'backfill', + tableId, + jobId, + status: 'ready', + progress: updated, + }) + logger.info(`[${requestId}] Backfill complete`, { tableId, groupId, processed, updated }) + } else { + logger.info(`[${requestId}] Backfill finished but no longer owns the run`, { tableId, jobId }) + } + } catch (err) { + if (err instanceof JobSupersededError) { + logger.info(`[${requestId}] Backfill superseded/canceled; stopping`, { tableId, jobId }) + } else { + const message = getErrorMessage(err, 'Backfill failed') + logger.error(`[${requestId}] Backfill failed for table ${tableId}:`, err) + await markJobFailed(tableId, jobId, message).catch(() => {}) + void appendTableEvent({ + kind: 'job', + type: 'backfill', + tableId, + jobId, + status: 'failed', + error: message, + }) + } + } +} + +/** + * Hybrid entry the schema-change flows call after adding/remapping workflow outputs. Small + * tables (≤ {@link BACKFILL_ASYNC_THRESHOLD_ROWS} completed runs) backfill inline-awaited, so the + * response returns with row data already consistent — identical to the historical behavior. Above + * the threshold, the work runs as a `table_jobs`-tracked background job (trigger.dev when + * enabled). The job slot is shared with import/delete; if another job holds it, the backfill is + * skipped with a warning — mirroring the long-standing "a failed backfill never fails the schema + * change" posture (the data stays backfillable). + */ +export async function maybeBackfillGroupOutputs(opts: { + table: TableDefinition + groupId: string + outputs: WorkflowGroupOutput[] + overwrite: boolean + requestId: string + actorUserId?: string | null +}): Promise { + const { table, groupId, outputs, overwrite, requestId, actorUserId } = opts + if (outputs.length === 0) return + + const [{ count: completedCount }] = await db + .select({ count: count() }) + .from(tableRowExecutions) + .where( + and( + eq(tableRowExecutions.tableId, table.id), + eq(tableRowExecutions.groupId, groupId), + eq(tableRowExecutions.status, 'completed') + ) + ) + const total = Number(completedCount) + if (total === 0) return + + if (total <= BACKFILL_ASYNC_THRESHOLD_ROWS) { + // Inline: page without job machinery so memory stays bounded but the caller can await + // full consistency. + let afterRowId: string | undefined + while (true) { + const execs = await selectCompletedExecPage(table.id, groupId, afterRowId, BACKFILL_PAGE_SIZE) + if (execs.length === 0) break + afterRowId = execs[execs.length - 1].rowId + await processBackfillPage({ table, outputs, overwrite, execs, requestId, actorUserId }) + } + return + } + + const jobId = generateId() + const jobPayload: TableBackfillJobPayload = { groupId, outputs, overwrite } + const claimed = await markTableJobRunning(table.id, jobId, 'backfill', jobPayload) + if (!claimed) { + logger.warn( + `[${requestId}] Skipping backfill for table ${table.id} group ${groupId}: another job is running` + ) + return + } + + const payload: TableBackfillPayload = { + jobId, + tableId: table.id, + workspaceId: table.workspaceId, + groupId, + outputs, + overwrite, + actorUserId, + } + if (isTriggerDevEnabled) { + try { + const [{ tableBackfillTask }, { tasks }] = await Promise.all([ + import('@/background/table-backfill'), + import('@trigger.dev/sdk'), + ]) + await tasks.trigger('table-backfill', payload, { + tags: [`tableId:${table.id}`, `jobId:${jobId}`], + }) + } catch (error) { + // Release the claim so a ghost `running` job doesn't block imports/deletes. + // Swallowed (warn only): a failed backfill never fails the schema change — + // the data stays backfillable. + const { releaseJobClaim } = await import('./service') + await releaseJobClaim(table.id, jobId).catch(() => {}) + logger.warn( + `[${requestId}] Backfill dispatch failed for table ${table.id} group ${groupId}; skipping`, + { error: getErrorMessage(error) } + ) + } + } else { + runDetached('table-backfill', () => runTableBackfill(payload)) + } +} diff --git a/apps/sim/lib/table/constants.ts b/apps/sim/lib/table/constants.ts index 04084ed8217..985dce4bf43 100644 --- a/apps/sim/lib/table/constants.ts +++ b/apps/sim/lib/table/constants.ts @@ -26,6 +26,15 @@ export const TABLE_LIMITS = { MAX_BULK_OPERATION_SIZE: 1000, /** Maximum rows a single clipboard copy/cut serializes; beyond this the user is steered to Export. */ MAX_COPY_ROWS: 50000, + /** Rows selected + deleted per page in the async background delete-job loop. Each page is one + * transaction (chunked into DELETE_BATCH_SIZE statements inside it); the page is also the + * cancel/ownership-check granularity. */ + DELETE_PAGE_SIZE: 10000, + /** Row count above which an export runs as a background job instead of a synchronous stream. + * Matches the default per-table row cap, so non-enterprise tables keep instant downloads. */ + EXPORT_ASYNC_THRESHOLD_ROWS: 10000, + /** Cap on the exclusion set ("select all, minus these") sent to an async delete job. */ + MAX_EXCLUDE_ROW_IDS: 10000, } as const /** diff --git a/apps/sim/lib/table/delete-runner.test.ts b/apps/sim/lib/table/delete-runner.test.ts new file mode 100644 index 00000000000..85e956a8067 --- /dev/null +++ b/apps/sim/lib/table/delete-runner.test.ts @@ -0,0 +1,131 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockGetTableById, + mockSelectRowIdPage, + mockDeletePageByIds, + mockUpdateJobProgress, + mockMarkJobReady, + mockMarkJobFailed, + mockAppendTableEvent, + mockBuildFilterClause, +} = vi.hoisted(() => ({ + mockGetTableById: vi.fn(), + mockSelectRowIdPage: vi.fn(), + mockDeletePageByIds: vi.fn(), + mockUpdateJobProgress: vi.fn(), + mockMarkJobReady: vi.fn(), + mockMarkJobFailed: vi.fn(), + mockAppendTableEvent: vi.fn(), + mockBuildFilterClause: vi.fn(), +})) + +vi.mock('@/lib/table/service', () => ({ + getTableById: mockGetTableById, + selectRowIdPage: mockSelectRowIdPage, + deletePageByIds: mockDeletePageByIds, + updateJobProgress: mockUpdateJobProgress, + markJobReady: mockMarkJobReady, + markJobFailed: mockMarkJobFailed, +})) +vi.mock('@/lib/table/events', () => ({ appendTableEvent: mockAppendTableEvent })) +vi.mock('@/lib/table/sql', () => ({ buildFilterClause: mockBuildFilterClause })) +vi.mock('@/lib/table/constants', () => ({ + TABLE_LIMITS: { DELETE_PAGE_SIZE: 2 }, + USER_TABLE_ROWS_SQL_NAME: 'user_table_rows', +})) + +import { runTableDelete } from '@/lib/table/delete-runner' + +const table = { id: 'tbl_1', workspaceId: 'ws_1', schema: { columns: [] } } +const cutoff = new Date('2026-06-05T00:00:00Z') + +function basePayload(overrides = {}) { + return { jobId: 'job_1', tableId: 'tbl_1', workspaceId: 'ws_1', cutoff, ...overrides } +} + +describe('runTableDelete', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetTableById.mockResolvedValue(table) + mockUpdateJobProgress.mockResolvedValue(true) + mockMarkJobReady.mockResolvedValue(true) + mockMarkJobFailed.mockResolvedValue(undefined) + mockDeletePageByIds.mockImplementation((_t, _w, ids: string[]) => Promise.resolve(ids.length)) + mockBuildFilterClause.mockReturnValue({}) + }) + + it('deletes every matching page then marks the job ready', async () => { + mockSelectRowIdPage + .mockResolvedValueOnce(['a', 'b']) + .mockResolvedValueOnce(['c']) + .mockResolvedValueOnce([]) + + await runTableDelete(basePayload({ filter: { status: 'old' } })) + + expect(mockDeletePageByIds).toHaveBeenNthCalledWith(1, 'tbl_1', 'ws_1', ['a', 'b']) + expect(mockDeletePageByIds).toHaveBeenNthCalledWith(2, 'tbl_1', 'ws_1', ['c']) + expect(mockMarkJobReady).toHaveBeenCalledWith('tbl_1', 'job_1') + expect(mockAppendTableEvent).toHaveBeenCalledWith( + expect.objectContaining({ kind: 'job', type: 'delete', status: 'ready', progress: 3 }) + ) + }) + + it('skips excluded rows but still advances the keyset cursor past them', async () => { + mockSelectRowIdPage.mockResolvedValueOnce(['keep', 'x']).mockResolvedValueOnce([]) + + await runTableDelete(basePayload({ excludeRowIds: ['keep'] })) + + expect(mockDeletePageByIds).toHaveBeenCalledTimes(1) + expect(mockDeletePageByIds).toHaveBeenCalledWith('tbl_1', 'ws_1', ['x']) + // Second page is queried after the last id of the first page (cursor advanced past 'keep'). + expect(mockSelectRowIdPage).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ afterId: 'x' }) + ) + expect(mockMarkJobReady).toHaveBeenCalled() + }) + + it('stops without marking ready when the ownership gate is lost (cancel/supersede)', async () => { + mockSelectRowIdPage.mockResolvedValue(['a', 'b']) + mockUpdateJobProgress.mockResolvedValueOnce(true).mockResolvedValueOnce(false) + + await runTableDelete(basePayload()) + + expect(mockDeletePageByIds).toHaveBeenCalledTimes(1) + expect(mockMarkJobReady).not.toHaveBeenCalled() + expect(mockMarkJobFailed).not.toHaveBeenCalled() + expect(mockAppendTableEvent).not.toHaveBeenCalledWith( + expect.objectContaining({ status: 'ready' }) + ) + }) + + it('marks the job failed and emits a failed event on error', async () => { + mockSelectRowIdPage.mockRejectedValue(new Error('boom')) + + await runTableDelete(basePayload()) + + expect(mockMarkJobFailed).toHaveBeenCalledWith('tbl_1', 'job_1', 'boom') + expect(mockAppendTableEvent).toHaveBeenCalledWith( + expect.objectContaining({ kind: 'job', type: 'delete', status: 'failed', error: 'boom' }) + ) + }) + + it('passes the cutoff and filter clause through to the page query', async () => { + mockSelectRowIdPage.mockResolvedValueOnce([]) + + await runTableDelete(basePayload({ filter: { status: 'old' } })) + + expect(mockBuildFilterClause).toHaveBeenCalledWith( + { status: 'old' }, + 'user_table_rows', + table.schema.columns + ) + expect(mockSelectRowIdPage).toHaveBeenCalledWith( + expect.objectContaining({ cutoff, filterClause: {}, limit: 2 }) + ) + }) +}) diff --git a/apps/sim/lib/table/delete-runner.ts b/apps/sim/lib/table/delete-runner.ts new file mode 100644 index 00000000000..840345c9f4f --- /dev/null +++ b/apps/sim/lib/table/delete-runner.ts @@ -0,0 +1,146 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import type { Filter } from '@/lib/table' +import { TABLE_LIMITS, USER_TABLE_ROWS_SQL_NAME } from '@/lib/table/constants' +import { appendTableEvent } from '@/lib/table/events' +import { + deletePageByIds, + getTableById, + markJobFailed, + markJobReady, + selectRowIdPage, + updateJobProgress, +} from '@/lib/table/service' +import { buildFilterClause } from '@/lib/table/sql' + +const logger = createLogger('TableDeleteRunner') + +/** Emit a progress event / heartbeat at most every this many rows. */ +const PROGRESS_INTERVAL_ROWS = 5000 + +/** + * Thrown when this worker discovers it no longer owns the table's job (canceled, or the + * stale-job janitor marked it failed and a newer job took over). The worker stops deleting. + */ +class JobSupersededError extends Error {} + +export interface TableDeletePayload { + jobId: string + tableId: string + workspaceId: string + /** Optional filter narrowing which rows to delete; omitted = every row at/under the cutoff. */ + filter?: Filter + /** Rows to spare ("select all, minus these"). Bounded by `MAX_EXCLUDE_ROW_IDS`. */ + excludeRowIds?: string[] + /** Only rows created at/before this instant are deleted, so mid-job inserts survive. */ + cutoff: Date +} + +/** + * Background worker for large filtered row deletes. Runs detached on the web container (see the + * delete-async kickoff route). Deletes in keyset-paginated pages — `created_at <= cutoff` spares + * rows inserted while the job runs, and `excludeRowIds` spares specific rows (the + * "select all then deselect a few" case). Ownership-gated per page so a cancel/supersede stops + * it within one page; committed pages are never rolled back. Progress and the terminal state are + * surfaced via the table-events SSE stream. + */ +export async function runTableDelete(payload: TableDeletePayload): Promise { + const { jobId, tableId, workspaceId, filter, excludeRowIds, cutoff } = payload + const requestId = generateId().slice(0, 8) + + try { + const table = await getTableById(tableId, { includeArchived: true }) + if (!table) throw new Error(`Delete target table ${tableId} not found`) + + const filterClause = filter + ? buildFilterClause(filter, USER_TABLE_ROWS_SQL_NAME, table.schema.columns) + : undefined + const excluded = new Set(excludeRowIds ?? []) + + let processed = 0 + let lastReported = 0 + let afterId: string | undefined + + while (true) { + // Ownership gate before every page: once this run loses the table (cancel/supersede), + // updateJobProgress returns false and we stop before deleting further. + const owns = await updateJobProgress(tableId, processed, jobId) + if (!owns) throw new JobSupersededError() + + const page = await selectRowIdPage({ + tableId, + workspaceId, + cutoff, + filterClause, + afterId, + limit: TABLE_LIMITS.DELETE_PAGE_SIZE, + }) + if (page.length === 0) break + // Advance the keyset cursor past the whole page — excluded ids are skipped (not deleted), + // so the cursor must move even when nothing in the page is deletable. + afterId = page[page.length - 1] + + const toDelete = excluded.size > 0 ? page.filter((id) => !excluded.has(id)) : page + if (toDelete.length > 0) { + processed += await deletePageByIds(tableId, workspaceId, toDelete) + } + + if ( + processed - lastReported >= PROGRESS_INTERVAL_ROWS || + (lastReported === 0 && processed > 0) + ) { + lastReported = processed + void appendTableEvent({ + kind: 'job', + type: 'delete', + tableId, + jobId, + status: 'running', + progress: processed, + }) + } + } + + await updateJobProgress(tableId, processed, jobId) + // Only announce success if we still won the transition — a cancel/supersede at the very end + // makes this a no-op, and we must not emit a false `ready`. + const becameReady = await markJobReady(tableId, jobId) + if (becameReady) { + void appendTableEvent({ + kind: 'job', + type: 'delete', + tableId, + jobId, + status: 'ready', + progress: processed, + }) + logger.info(`[${requestId}] Delete complete`, { tableId, rows: processed }) + } else { + logger.info( + `[${requestId}] Delete finished but no longer owns the run (canceled/superseded)`, + { + tableId, + jobId, + } + ) + } + } catch (err) { + if (err instanceof JobSupersededError) { + logger.info(`[${requestId}] Delete superseded by a newer run; stopping`, { tableId, jobId }) + } else { + const message = getErrorMessage(err, 'Delete failed') + logger.error(`[${requestId}] Delete failed for table ${tableId}:`, err) + // Scoped to jobId — a no-op if a newer job has taken over. + await markJobFailed(tableId, jobId, message).catch(() => {}) + void appendTableEvent({ + kind: 'job', + type: 'delete', + tableId, + jobId, + status: 'failed', + error: message, + }) + } + } +} diff --git a/apps/sim/lib/table/dispatcher.ts b/apps/sim/lib/table/dispatcher.ts index 998b701363d..7d0721e645d 100644 --- a/apps/sim/lib/table/dispatcher.ts +++ b/apps/sim/lib/table/dispatcher.ts @@ -3,12 +3,27 @@ import { tableRowExecutions, tableRunDispatches, userTableRows } from '@sim/db/s import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' -import { and, asc, eq, gt, inArray, isNotNull, ne, or, type SQL, sql } from 'drizzle-orm' +import { + and, + asc, + eq, + gt, + inArray, + isNotNull, + ne, + notInArray, + or, + type SQL, + sql, +} from 'drizzle-orm' import { getJobQueue } from '@/lib/core/async-jobs/config' import { writeWorkflowGroupState } from '@/lib/table/cell-write' +import { USER_TABLE_ROWS_SQL_NAME } from '@/lib/table/constants' import { isExecCancelledAfter } from '@/lib/table/deps' import { appendTableEvent } from '@/lib/table/events' -import type { RowExecutionMetadata, RowExecutions, TableRow } from '@/lib/table/types' +import { type DbExecutor, withSeqscanOff } from '@/lib/table/planner' +import { buildFilterClause } from '@/lib/table/sql' +import type { Filter, RowExecutionMetadata, RowExecutions, TableRow } from '@/lib/table/types' import { buildEnqueueItems, buildPendingRuns, @@ -32,6 +47,11 @@ export type DispatchMode = 'all' | 'incomplete' | 'new' export interface DispatchScope { groupIds: string[] rowIds?: string[] + /** "Select all matching a filter" — run every row matching this filter (mutually exclusive with + * `rowIds`). Lets the action-bar Play/Refresh target a filtered view without materializing ids. */ + filter?: Filter + /** Select-all scope only: deselected rows the walk skips (mirrors the delete job's exclusion set). */ + excludeRowIds?: string[] } /** @@ -76,9 +96,11 @@ export async function bulkClearWorkflowGroupCells(input: { tableId: string groups: Array<{ id: string; outputs: Array<{ columnName: string }> }> rowIds?: string[] + /** Select-all scope: deselected rows whose outputs must NOT be wiped. */ + excludeRowIds?: string[] mode: DispatchMode }): Promise { - const { tableId, groups, rowIds, mode } = input + const { tableId, groups, rowIds, excludeRowIds, mode } = input if (groups.length === 0) return // `'new'` mode targets only rows with no prior attempt — nothing to clear. // Pre-existing outputs on any other row must not be wiped by an auto-fire. @@ -86,6 +108,7 @@ export async function bulkClearWorkflowGroupCells(input: { const groupIds = groups.map((g) => g.id) const rowScope = rowIds && rowIds.length > 0 ? rowIds : null + const excluded = !rowScope && excludeRowIds && excludeRowIds.length > 0 ? excludeRowIds : null if (mode === 'all') { // Run-all re-runs every targeted group: wipe all their output columns + @@ -98,6 +121,7 @@ export async function bulkClearWorkflowGroupCells(input: { for (const col of outputCols) dataExpr = sql`(${dataExpr}) - ${col}::text` const filters: SQL[] = [eq(userTableRows.tableId, tableId)] if (rowScope) filters.push(inArray(userTableRows.id, rowScope)) + if (excluded) filters.push(notInArray(userTableRows.id, excluded)) await db.transaction(async (trx) => { await trx @@ -109,6 +133,7 @@ export async function bulkClearWorkflowGroupCells(input: { inArray(tableRowExecutions.groupId, groupIds), ] if (rowScope) execFilters.push(inArray(tableRowExecutions.rowId, rowScope)) + if (excluded) execFilters.push(notInArray(tableRowExecutions.rowId, excluded)) await trx.delete(tableRowExecutions).where(and(...execFilters)) }) return @@ -131,6 +156,7 @@ export async function bulkClearWorkflowGroupCells(input: { )` const filters: SQL[] = [eq(userTableRows.tableId, tableId), reRunnable] if (rowScope) filters.push(inArray(userTableRows.id, rowScope)) + if (excluded) filters.push(notInArray(userTableRows.id, excluded)) let dataExpr: SQL = sql`coalesce(${userTableRows.data}, '{}'::jsonb)` for (const out of group.outputs) dataExpr = sql`(${dataExpr}) - ${out.columnName}::text` @@ -145,6 +171,7 @@ export async function bulkClearWorkflowGroupCells(input: { sql`${tableRowExecutions.status} IN ('error', 'cancelled')`, ] if (rowScope) execFilters.push(inArray(tableRowExecutions.rowId, rowScope)) + if (excluded) execFilters.push(notInArray(tableRowExecutions.rowId, excluded)) await trx.delete(tableRowExecutions).where(and(...execFilters)) } }) @@ -394,8 +421,24 @@ export async function dispatcherStep(dispatchId: string): Promise 0) { filters.push(inArray(userTableRows.id, dispatch.scope.rowIds)) + } else if (dispatch.scope.filter) { + // "Select all under a filter": walk only the matching rows. Same cursor/window mechanism — + // non-matching rows are simply never selected, like mode eligibility. + const filterClause = buildFilterClause( + dispatch.scope.filter, + USER_TABLE_ROWS_SQL_NAME, + table.schema.columns + ) + if (filterClause) { + filters.push(filterClause) + hasJsonbFilter = true + } + } + if (!dispatch.scope.rowIds?.length && dispatch.scope.excludeRowIds?.length) { + filters.push(notInArray(userTableRows.id, dispatch.scope.excludeRowIds)) } // `'new'` mode targets only rows whose targeted groups haven't been // attempted. Exclude a row only when EVERY targeted group already has a @@ -417,12 +460,18 @@ export async function dispatcherStep(dispatchId: string): Promise + executor + .select() + .from(userTableRows) + .where(and(...filters)) + .orderBy(asc(userTableRows.position)) + .limit(WINDOW_SIZE) + // Filtered scopes carry a jsonb predicate the planner can't estimate — left alone it + // seq-scans the whole shared relation per window; keep it on the tenant's position index. + const chunk = hasJsonbFilter + ? await withSeqscanOff(async (trx) => windowQuery(trx)) + : await windowQuery(db) if (chunk.length === 0) { await markDispatchComplete(dispatchId) @@ -699,15 +748,23 @@ export async function markDispatchCancelled(dispatchId: string): Promise { * UPDATE so the dispatcher's next iteration observes the cancel. Returns the * dispatches that were cancelled so the caller can emit per-dispatch SSE * events — without those the client's overlay would hang on "queued" until - * the next refresh. */ -export async function markActiveDispatchesCancelled(tableId: string): Promise { + * the next refresh. Pass `scopeFilter` to cancel only dispatches whose scope + * is that exact filter (a filtered "select all" Stop must not halt + * whole-table or differently-filtered runs). */ +export async function markActiveDispatchesCancelled( + tableId: string, + scopeFilter?: Filter +): Promise { const cancelled = await db .update(tableRunDispatches) .set({ status: 'cancelled', cancelledAt: new Date() }) .where( and( eq(tableRunDispatches.tableId, tableId), - inArray(tableRunDispatches.status, [...ACTIVE_DISPATCH_STATUSES]) + inArray(tableRunDispatches.status, [...ACTIVE_DISPATCH_STATUSES]), + scopeFilter + ? sql`${tableRunDispatches.scope}->'filter' = ${JSON.stringify(scopeFilter)}::jsonb` + : undefined ) ) .returning() diff --git a/apps/sim/lib/table/events.ts b/apps/sim/lib/table/events.ts index 24156409a16..86a6f7ec09d 100644 --- a/apps/sim/lib/table/events.ts +++ b/apps/sim/lib/table/events.ts @@ -114,15 +114,17 @@ export type TableEvent = limit?: { type: 'rows'; max: number } } | { - /** Async large-import progress. The background import worker emits - * `importing` ticks as batches commit, then a terminal `ready`/`failed`. - * The client reveals the (hidden) rows on `ready` and shows a failure - * badge on `failed`. See `apps/sim/lib/table/import-runner.ts`. */ - kind: 'import' + /** Async background-job progress. Import and delete workers emit `running` + * ticks as batches commit, then a terminal `ready`/`failed`/`canceled`. + * `type` discriminates the work. The client reveals hidden import rows on + * `ready`, and on a delete `failed`/`canceled` restores optimistically + * hidden rows. See `import-runner.ts` / `delete-runner.ts`. */ + kind: 'job' tableId: string - importId: string - status: 'importing' | 'ready' | 'failed' | 'canceled' - /** Rows committed so far (importing) or in total (ready). */ + jobId: string + type: 'import' | 'delete' | 'export' | 'backfill' + status: 'running' | 'ready' | 'failed' | 'canceled' + /** Rows processed so far (running) or in total (ready). */ progress?: number /** Byte-based completion percent (0–100) — exact and monotonic, for the determinate bar. */ percent?: number diff --git a/apps/sim/lib/table/export-format.ts b/apps/sim/lib/table/export-format.ts new file mode 100644 index 00000000000..3d5256b13fc --- /dev/null +++ b/apps/sim/lib/table/export-format.ts @@ -0,0 +1,41 @@ +/** + * Shared serialization for table exports — used by both the synchronous streaming export route + * (small tables) and the background export job worker (large tables), so the two paths produce + * byte-identical files. + */ + +export function sanitizeExportFilename(name: string): string { + const cleaned = name.replace(/[^a-zA-Z0-9_-]+/g, '_').replace(/^_+|_+$/g, '') + return cleaned || 'table' +} + +/** + * Prefixes a single quote to values starting with a spreadsheet formula trigger + * (`=`, `+`, `-`, `@`, tab, CR), neutralizing CSV injection in Excel/Sheets. + */ +export function neutralizeCsvFormula(value: string): string { + return /^[=+\-@\t\r]/.test(value) ? `'${value}` : value +} + +/** + * Serializes a cell for CSV. Only string cells are formula-neutralized; numbers, + * booleans, dates, and JSON objects can never form a trigger and pass through verbatim. + */ +export function formatCsvValue(value: unknown): string { + if (value === null || value === undefined) return '' + if (value instanceof Date) return value.toISOString() + if (typeof value === 'object') return JSON.stringify(value) + if (typeof value === 'string') return neutralizeCsvFormula(value) + return String(value) +} + +export function toCsvRow(values: string[]): string { + return values.map(escapeCsvField).join(',') +} + +function escapeCsvField(field: string): string { + if (/[",\n\r]/.test(field)) { + return `"${field.replace(/"/g, '""')}"` + } + return field +} diff --git a/apps/sim/lib/table/export-runner.test.ts b/apps/sim/lib/table/export-runner.test.ts new file mode 100644 index 00000000000..04ee5f84664 --- /dev/null +++ b/apps/sim/lib/table/export-runner.test.ts @@ -0,0 +1,131 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockGetTableById, + mockSelectExportRowPage, + mockUpdateJobProgress, + mockMarkJobReady, + mockMarkJobFailed, + mockSetJobResultKey, + mockAppendTableEvent, + mockUploadFile, + mockDeleteFile, +} = vi.hoisted(() => ({ + mockGetTableById: vi.fn(), + mockSelectExportRowPage: vi.fn(), + mockUpdateJobProgress: vi.fn(), + mockMarkJobReady: vi.fn(), + mockMarkJobFailed: vi.fn(), + mockSetJobResultKey: vi.fn(), + mockAppendTableEvent: vi.fn(), + mockUploadFile: vi.fn(), + mockDeleteFile: vi.fn(), +})) + +vi.mock('@/lib/table/service', () => ({ + getTableById: mockGetTableById, + selectExportRowPage: mockSelectExportRowPage, + updateJobProgress: mockUpdateJobProgress, + markJobReady: mockMarkJobReady, + markJobFailed: mockMarkJobFailed, + setJobResultKey: mockSetJobResultKey, +})) +vi.mock('@/lib/table/events', () => ({ appendTableEvent: mockAppendTableEvent })) +vi.mock('@/lib/uploads/core/storage-service', () => ({ + uploadFile: mockUploadFile, + deleteFile: mockDeleteFile, +})) + +import { runTableExport } from '@/lib/table/export-runner' + +const table = { + id: 'tbl_1', + name: 'People', + workspaceId: 'ws_1', + schema: { columns: [{ id: 'col_name', name: 'name', type: 'string' }] }, +} + +const payload = { jobId: 'job_1', tableId: 'tbl_1', workspaceId: 'ws_1', format: 'csv' as const } + +describe('runTableExport', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetTableById.mockResolvedValue(table) + mockUpdateJobProgress.mockResolvedValue(true) + mockMarkJobReady.mockResolvedValue(true) + mockMarkJobFailed.mockResolvedValue(undefined) + mockSetJobResultKey.mockResolvedValue(undefined) + // Echo the requested key back like preserveKey-aware providers do; the runner must record + // THIS returned key, not its own constructed one. + mockUploadFile.mockImplementation((opts: { customKey: string }) => + Promise.resolve({ key: opts.customKey }) + ) + mockDeleteFile.mockResolvedValue(undefined) + mockSelectExportRowPage.mockResolvedValue([ + { id: 'r1', data: { col_name: 'Ada' }, position: 0 }, + ]) + }) + + it('pages rows, uploads the file, stamps the result key, and marks ready', async () => { + await runTableExport(payload) + + expect(mockUploadFile).toHaveBeenCalledTimes(1) + const upload = mockUploadFile.mock.calls[0][0] + expect(upload.customKey).toBe('workspace/ws_1/exports/tbl_1/job_1/People.csv') + expect(upload.preserveKey).toBe(true) + expect(upload.contentType).toContain('text/csv') + expect(upload.file.toString('utf8')).toBe('name\nAda\n') + + expect(mockSetJobResultKey).toHaveBeenCalledWith('tbl_1', 'job_1', upload.customKey) + expect(mockMarkJobReady).toHaveBeenCalledWith('tbl_1', 'job_1') + expect(mockAppendTableEvent).toHaveBeenCalledWith( + expect.objectContaining({ kind: 'job', type: 'export', status: 'ready', progress: 1 }) + ) + expect(mockDeleteFile).not.toHaveBeenCalled() + }) + + it('serializes JSON exports with display-name keys', async () => { + await runTableExport({ ...payload, format: 'json' }) + const upload = mockUploadFile.mock.calls[0][0] + expect(upload.customKey.endsWith('/People.json')).toBe(true) + expect(JSON.parse(upload.file.toString('utf8'))).toEqual([{ name: 'Ada' }]) + }) + + it('stops without uploading when the ownership gate is lost (cancel)', async () => { + mockUpdateJobProgress.mockResolvedValue(false) + + await runTableExport(payload) + + expect(mockUploadFile).not.toHaveBeenCalled() + expect(mockMarkJobReady).not.toHaveBeenCalled() + expect(mockMarkJobFailed).not.toHaveBeenCalled() + }) + + it('cleans up an orphaned upload when the job was canceled at the wire', async () => { + mockMarkJobReady.mockResolvedValue(false) + + await runTableExport(payload) + + expect(mockUploadFile).toHaveBeenCalledTimes(1) + expect(mockDeleteFile).toHaveBeenCalledWith( + expect.objectContaining({ key: expect.stringContaining('exports/tbl_1/job_1') }) + ) + expect(mockAppendTableEvent).not.toHaveBeenCalledWith( + expect.objectContaining({ status: 'ready' }) + ) + }) + + it('marks the job failed and emits a failed event on error', async () => { + mockSelectExportRowPage.mockRejectedValue(new Error('boom')) + + await runTableExport(payload) + + expect(mockMarkJobFailed).toHaveBeenCalledWith('tbl_1', 'job_1', 'boom') + expect(mockAppendTableEvent).toHaveBeenCalledWith( + expect.objectContaining({ kind: 'job', type: 'export', status: 'failed', error: 'boom' }) + ) + }) +}) diff --git a/apps/sim/lib/table/export-runner.ts b/apps/sim/lib/table/export-runner.ts new file mode 100644 index 00000000000..93cb201cffd --- /dev/null +++ b/apps/sim/lib/table/export-runner.ts @@ -0,0 +1,150 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { buildNameById, getColumnId, rowDataIdToName } from '@/lib/table/column-keys' +import { appendTableEvent } from '@/lib/table/events' +import { + formatCsvValue, + neutralizeCsvFormula, + sanitizeExportFilename, + toCsvRow, +} from '@/lib/table/export-format' +import { + getTableById, + markJobFailed, + markJobReady, + selectExportRowPage, + setJobResultKey, + updateJobProgress, +} from '@/lib/table/service' +import { deleteFile, uploadFile } from '@/lib/uploads/core/storage-service' + +const logger = createLogger('TableExportRunner') + +/** Rows per page while building the file. Internal caller — not bound by MAX_QUERY_LIMIT; rows + * are fetched without executions, so even wide rows stay a few MB per batch. */ +const EXPORT_BATCH_SIZE = 5000 + +/** Thrown when this worker loses the job (canceled / janitor-failed). */ +class JobSupersededError extends Error {} + +export interface TableExportPayload { + jobId: string + tableId: string + workspaceId: string + format: 'csv' | 'json' +} + +/** + * Background worker for large table exports. Pages rows via `queryRows` (so the delete-job + * visibility mask applies — an export taken mid-delete excludes the doomed rows), accumulates the + * serialized file, uploads it to workspace storage, and stamps the storage key onto the job's + * payload (`resultKey`). The client downloads via a presigned URL from the download route; the + * janitor deletes the file when the terminal job is pruned. Ownership-gated per batch, so a + * cancel stops it within one page. Retry-safe: a retried attempt regenerates the file from + * scratch and overwrites nothing (fresh key per attempt; failures clean up their partial upload). + */ +export async function runTableExport(payload: TableExportPayload): Promise { + const { jobId, tableId, workspaceId, format } = payload + const requestId = generateId().slice(0, 8) + let uploadedKey: string | null = null + + try { + const table = await getTableById(tableId, { includeArchived: true }) + if (!table) throw new Error(`Export target table ${tableId} not found`) + + const columns = table.schema.columns + // Stored row data is id-keyed; CSV headers and JSON keys are display names, so translate + // id → name on the way out (export is a name-friendly boundary). + const nameById = buildNameById(table.schema) + + const chunks: string[] = [] + if (format === 'csv') { + chunks.push(`${toCsvRow(columns.map((c) => neutralizeCsvFormula(c.name)))}\n`) + } else { + chunks.push('[') + } + + let exported = 0 + let firstJsonRow = true + let after: { position: number; id: string } | null = null + while (true) { + // Ownership gate before every page: a canceled job stops within one batch. + const owns = await updateJobProgress(tableId, exported, jobId) + if (!owns) throw new JobSupersededError() + + const page = await selectExportRowPage(table, after, EXPORT_BATCH_SIZE) + if (page.length === 0) break + + for (const row of page) { + if (format === 'csv') { + chunks.push(`${toCsvRow(columns.map((c) => formatCsvValue(row.data[getColumnId(c)])))}\n`) + } else { + const prefix = firstJsonRow ? '' : ',' + firstJsonRow = false + chunks.push(prefix + JSON.stringify(rowDataIdToName(row.data, nameById))) + } + } + + exported += page.length + const last = page[page.length - 1] + after = { position: last.position, id: last.id } + if (page.length < EXPORT_BATCH_SIZE) break + } + if (format === 'json') chunks.push(']') + + const fileName = `${sanitizeExportFilename(table.name)}.${format}` + const key = `workspace/${workspaceId}/exports/${tableId}/${jobId}/${fileName}` + // `preserveKey` keeps the custom key verbatim (without it the provider rewrites the key to a + // timestamped, path-stripped name), and the *returned* key is recorded as the source of truth + // either way — the download route presigns exactly what was written. + const uploaded = await uploadFile({ + file: Buffer.from(chunks.join(''), 'utf8'), + fileName, + contentType: format === 'csv' ? 'text/csv; charset=utf-8' : 'application/json', + context: 'workspace', + customKey: key, + preserveKey: true, + }) + uploadedKey = uploaded.key + await setJobResultKey(tableId, jobId, uploaded.key) + + await updateJobProgress(tableId, exported, jobId) + // Only announce success if we still won the transition (not canceled at the wire). + const becameReady = await markJobReady(tableId, jobId) + if (becameReady) { + void appendTableEvent({ + kind: 'job', + type: 'export', + tableId, + jobId, + status: 'ready', + progress: exported, + }) + logger.info(`[${requestId}] Export complete`, { tableId, rows: exported, format }) + } else { + // Canceled at the very end — the file is orphaned; remove it (janitor would otherwise + // only catch it via the pruned job's resultKey). + await deleteFile({ key: uploaded.key, context: 'workspace' }).catch(() => {}) + logger.info(`[${requestId}] Export finished but no longer owns the run`, { tableId, jobId }) + } + } catch (err) { + // A partial/orphaned upload from this attempt is useless — clean it up best-effort. + if (uploadedKey) await deleteFile({ key: uploadedKey, context: 'workspace' }).catch(() => {}) + if (err instanceof JobSupersededError) { + logger.info(`[${requestId}] Export superseded/canceled; stopping`, { tableId, jobId }) + } else { + const message = getErrorMessage(err, 'Export failed') + logger.error(`[${requestId}] Export failed for table ${tableId}:`, err) + await markJobFailed(tableId, jobId, message).catch(() => {}) + void appendTableEvent({ + kind: 'job', + type: 'export', + tableId, + jobId, + status: 'failed', + error: message, + }) + } + } +} diff --git a/apps/sim/lib/table/import-runner.ts b/apps/sim/lib/table/import-runner.ts index 64593044178..44a7d3ec693 100644 --- a/apps/sim/lib/table/import-runner.ts +++ b/apps/sim/lib/table/import-runner.ts @@ -24,12 +24,12 @@ import { bulkInsertImportBatch, deleteAllTableRows, getTableById, - markImportFailed, - markImportReady, + markJobFailed, + markJobReady, nextImportStartOrderKey, nextImportStartPosition, setTableSchemaForImport, - updateImportProgress, + updateJobProgress, } from '@/lib/table/service' import { deleteFile, downloadFileStream, headObject } from '@/lib/uploads/core/storage-service' import { normalizeColumn } from '@/app/api/table/utils' @@ -185,9 +185,9 @@ export async function runTableImport(payload: TableImportPayload): Promise const flush = async (rows: Record[]) => { if (rows.length === 0 || !schema || !headerToColumn) return // Ownership gate before every insert: once this run loses the table (cancel/supersede), - // updateImportProgress returns false and we stop before writing into a table a newer import + // updateJobProgress returns false and we stop before writing into a table a newer import // may own. Runs per batch (not just at the emit cadence) so we stop within one batch. - const owns = await updateImportProgress(tableId, inserted, importId) + const owns = await updateJobProgress(tableId, inserted, importId) if (!owns) throw new ImportSupersededError() const coerced = coerceRowsForTable(rows, schema, headerToColumn) const result = await bulkInsertImportBatch( @@ -214,10 +214,11 @@ export async function runTableImport(payload: TableImportPayload): Promise const percent = totalBytes > 0 ? Math.min(99, Math.round((bytesRead / totalBytes) * 100)) : undefined void appendTableEvent({ - kind: 'import', + kind: 'job', + type: 'import', tableId, - importId, - status: 'importing', + jobId: importId, + status: 'running', progress: inserted, percent, }) @@ -247,11 +248,12 @@ export async function runTableImport(payload: TableImportPayload): Promise if (sample.length === 0) { // No data rows — fail rather than report a successful empty import (matches the sync route). const message = 'CSV file has no data rows' - await markImportFailed(tableId, importId, message) + await markJobFailed(tableId, importId, message) void appendTableEvent({ - kind: 'import', + kind: 'job', + type: 'import', tableId, - importId, + jobId: importId, status: 'failed', error: message, }) @@ -277,15 +279,16 @@ export async function runTableImport(payload: TableImportPayload): Promise await flush(batch) } - await updateImportProgress(tableId, inserted, importId) + await updateJobProgress(tableId, inserted, importId) // Only announce success if we actually won the transition — a cancel/supersede that landed // right at the end makes this a no-op, and we must not emit a false `ready`. - const becameReady = await markImportReady(tableId, importId) + const becameReady = await markJobReady(tableId, importId) if (becameReady) { void appendTableEvent({ - kind: 'import', + kind: 'job', + type: 'import', tableId, - importId, + jobId: importId, status: 'ready', progress: inserted, percent: 100, @@ -323,8 +326,15 @@ export async function runTableImport(payload: TableImportPayload): Promise const message = getErrorMessage(err, 'Import failed') logger.error(`[${requestId}] Import failed for table ${tableId}:`, err) // Scoped to importId — a no-op if a newer import has taken over. - await markImportFailed(tableId, importId, message).catch(() => {}) - void appendTableEvent({ kind: 'import', tableId, importId, status: 'failed', error: message }) + await markJobFailed(tableId, importId, message).catch(() => {}) + void appendTableEvent({ + kind: 'job', + type: 'import', + tableId, + jobId: importId, + status: 'failed', + error: message, + }) captureServerEvent( userId, 'table_import_completed', diff --git a/apps/sim/lib/table/import.ts b/apps/sim/lib/table/import.ts index 1584c9826c6..a132ba96dd7 100644 --- a/apps/sim/lib/table/import.ts +++ b/apps/sim/lib/table/import.ts @@ -50,8 +50,13 @@ export type CsvColumnType = 'string' | 'number' | 'boolean' | 'date' | 'json' /** Number of CSV rows sampled when inferring column types for a new table. */ export const CSV_SCHEMA_SAMPLE_SIZE = 100 -/** Maximum rows inserted per `batchInsertRows` call during import. */ -export const CSV_MAX_BATCH_SIZE = 1000 +/** + * Maximum rows inserted per import batch. Each batch is one `INSERT … VALUES` statement, and + * Postgres caps bind parameters at 65,535 — at 9 params per row that's a hard ceiling of ~7,200 + * rows, so 5,000 keeps a margin while cutting per-batch overhead (validation, unique-constraint + * check, ownership heartbeat) 5× vs the old 1,000. + */ +export const CSV_MAX_BATCH_SIZE = 5000 /** Maximum CSV/TSV file size accepted by import routes (25 MB). */ export const CSV_MAX_FILE_SIZE_BYTES = 25 * 1024 * 1024 diff --git a/apps/sim/lib/table/planner.ts b/apps/sim/lib/table/planner.ts new file mode 100644 index 00000000000..848e2c2d6de --- /dev/null +++ b/apps/sim/lib/table/planner.ts @@ -0,0 +1,23 @@ +import { db } from '@sim/db' +import { sql } from 'drizzle-orm' + +export type DbExecutor = typeof db | DbTransaction +export type DbTransaction = Parameters[0]>[0] + +/** + * Runs `fn` with seq scans penalized (`SET LOCAL`, so the flag dies with the + * transaction). JSONB predicates and sort keys (`->>` extraction, `@>` + * containment, lateral `jsonb_each_text`) are opaque to the planner — it + * estimates a handful of matching rows and picks a parallel seq scan over the + * entire shared `user_table_rows` relation (every tenant's rows) instead of the + * tenant's own index. Measured on a 1M-row table inside a 12M-row relation: + * filtered count 12.7s → 1.0s, sorted page 9.7s → 0.76s, filtered bulk select + * 14.4s → tenant-bounded. The flag only penalizes the plan shape: if no index + * plan exists, the seq scan still runs. + */ +export async function withSeqscanOff(fn: (trx: DbTransaction) => Promise): Promise { + return db.transaction(async (trx) => { + await trx.execute(sql`SET LOCAL enable_seqscan = off`) + return fn(trx) + }) +} diff --git a/apps/sim/lib/table/service.ts b/apps/sim/lib/table/service.ts index 429ac7bb04e..6b9f62e7d68 100644 --- a/apps/sim/lib/table/service.ts +++ b/apps/sim/lib/table/service.ts @@ -8,14 +8,9 @@ */ import { db } from '@sim/db' -import { - tableRowExecutions, - userTableDefinitions, - userTableRows, - workflowExecutionLogs, -} from '@sim/db/schema' +import { tableJobs, tableRowExecutions, userTableDefinitions, userTableRows } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { getPostgresErrorCode } from '@sim/utils/errors' +import { getPostgresErrorCode, toError } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { and, @@ -28,16 +23,16 @@ import { inArray, isNull, lt, + lte, ne, + notInArray, or, type SQL, sql, } from 'drizzle-orm' import { isTablesFractionalOrderingEnabled } from '@/lib/core/config/feature-flags' -import { MATERIALIZE_CONCURRENCY, mapWithConcurrency } from '@/lib/core/utils/concurrency' import { generateRestoreName } from '@/lib/core/utils/restore-name' import type { DbOrTx } from '@/lib/db/types' -import { materializeExecutionData } from '@/lib/logs/execution/trace-store' import { columnMatchesRef, generateColumnId, @@ -49,6 +44,7 @@ import { COLUMN_TYPES, NAME_PATTERN, TABLE_LIMITS, USER_TABLE_ROWS_SQL_NAME } fr import { areGroupDepsSatisfied } from './deps' import { CSV_MAX_BATCH_SIZE } from './import' import { keyBetween, nKeysBetween } from './order-key' +import { type DbExecutor, type DbTransaction, withSeqscanOff } from './planner' import { buildFilterClause, buildSortClause, escapeLikePattern } from './sql' import { fireTableTrigger } from './trigger' import type { @@ -76,6 +72,9 @@ import type { RowExecutions, Sort, TableDefinition, + TableDeleteJobPayload, + TableExportJobPayload, + TableJobType, TableMetadata, TableRow, TableSchema, @@ -116,8 +115,6 @@ export class TableConflictError extends Error { export type TableScope = 'active' | 'archived' | 'all' -type DbTransaction = Parameters[0]>[0] - /** * Sets per-transaction Postgres timeouts via `SET LOCAL`. * @@ -253,6 +250,78 @@ function applyColumnOrderToSchema( return { ...schema, columns: ordered } } +/** Job fields projected onto a {@link TableDefinition}, derived from its latest `table_jobs` row. */ +interface DerivedJobFields { + jobStatus: TableDefinition['jobStatus'] + jobId: string | null + jobType: TableDefinition['jobType'] + jobError: string | null + jobRowsProcessed: number +} + +const EMPTY_JOB_FIELDS: DerivedJobFields = { + jobStatus: null, + jobId: null, + jobType: null, + jobError: null, + jobRowsProcessed: 0, +} + +function mapJobRow( + row: + | { id: string; type: string; status: string; rowsProcessed: number; error: string | null } + | undefined +): DerivedJobFields { + if (!row) return EMPTY_JOB_FIELDS + return { + jobStatus: row.status as TableDefinition['jobStatus'], + jobId: row.id, + jobType: row.type as TableDefinition['jobType'], + jobError: row.error, + jobRowsProcessed: row.rowsProcessed, + } +} + +const JOB_PROJECTION = { + id: tableJobs.id, + type: tableJobs.type, + status: tableJobs.status, + rowsProcessed: tableJobs.rowsProcessed, + error: tableJobs.error, +} as const + +/** + * The latest job for one table (the running one if present, else the most recent terminal). + * Exports are excluded: they're read-only, run concurrently with other jobs, and have their own + * client surface — surfacing one here would clobber the import/delete/backfill status the tray + * and SSE consumer derive from these fields. + */ +async function latestJobForTable( + tableId: string, + executor: DbOrTx = db +): Promise { + const [row] = await executor + .select(JOB_PROJECTION) + .from(tableJobs) + .where(and(eq(tableJobs.tableId, tableId), ne(tableJobs.type, 'export'))) + .orderBy(desc(tableJobs.startedAt)) + .limit(1) + return mapJobRow(row) +} + +/** Latest non-export job per table for a batch of ids, via `DISTINCT ON (table_id)`. */ +async function latestJobsForTables(tableIds: string[]): Promise> { + const map = new Map() + if (tableIds.length === 0) return map + const rows = await db + .selectDistinctOn([tableJobs.tableId], { tableId: tableJobs.tableId, ...JOB_PROJECTION }) + .from(tableJobs) + .where(and(inArray(tableJobs.tableId, tableIds), ne(tableJobs.type, 'export'))) + .orderBy(tableJobs.tableId, desc(tableJobs.startedAt)) + for (const row of rows) map.set(row.tableId, mapJobRow(row)) + return map +} + export async function getTableById( tableId: string, options?: { includeArchived?: boolean; tx?: DbOrTx } @@ -273,11 +342,6 @@ export async function getTableById( createdAt: userTableDefinitions.createdAt, updatedAt: userTableDefinitions.updatedAt, rowCount: userTableDefinitions.rowCount, - importStatus: userTableDefinitions.importStatus, - importId: userTableDefinitions.importId, - importError: userTableDefinitions.importError, - importRowsProcessed: userTableDefinitions.importRowsProcessed, - importStartedAt: userTableDefinitions.importStartedAt, }) .from(userTableDefinitions) .where( @@ -304,11 +368,7 @@ export async function getTableById( archivedAt: table.archivedAt, createdAt: table.createdAt, updatedAt: table.updatedAt, - importStatus: table.importStatus as TableDefinition['importStatus'], - importId: table.importId, - importError: table.importError, - importRowsProcessed: table.importRowsProcessed, - importStartedAt: table.importStartedAt, + ...(await latestJobForTable(tableId, executor)), } } @@ -350,11 +410,6 @@ export async function listTables( createdAt: userTableDefinitions.createdAt, updatedAt: userTableDefinitions.updatedAt, rowCount: userTableDefinitions.rowCount, - importStatus: userTableDefinitions.importStatus, - importId: userTableDefinitions.importId, - importError: userTableDefinitions.importError, - importRowsProcessed: userTableDefinitions.importRowsProcessed, - importStartedAt: userTableDefinitions.importStartedAt, }) .from(userTableDefinitions) .where( @@ -372,6 +427,8 @@ export async function listTables( ) .orderBy(userTableDefinitions.createdAt) + const jobsByTable = await latestJobsForTables(tables.map((t) => t.id)) + return tables.map((t) => { const metadata = (t.metadata as TableMetadata) ?? null return { @@ -387,11 +444,7 @@ export async function listTables( archivedAt: t.archivedAt, createdAt: t.createdAt, updatedAt: t.updatedAt, - importStatus: t.importStatus as TableDefinition['importStatus'], - importId: t.importId, - importError: t.importError, - importRowsProcessed: t.importRowsProcessed, - importStartedAt: t.importStartedAt, + ...(jobsByTable.get(t.id) ?? EMPTY_JOB_FIELDS), } }) } @@ -441,11 +494,14 @@ export async function createTable( archivedAt: null, createdAt: now, updatedAt: now, - importStatus: data.importStatus ?? null, - importId: data.importId ?? null, - importStartedAt: data.importStatus ? now : null, } + // Create-mode CSV import is born with a running job so its rows stay hidden until ready. + const initialJob = + data.jobStatus === 'running' && data.jobId + ? { id: data.jobId, type: data.jobType ?? 'import', startedAt: now } + : null + // Wrap count check, duplicate check, and insert in a transaction with FOR UPDATE // to prevent TOCTOU race on the table count limit try { @@ -485,6 +541,18 @@ export async function createTable( await trx.insert(userTableDefinitions).values(newTable) + if (initialJob) { + await trx.insert(tableJobs).values({ + id: initialJob.id, + tableId, + workspaceId: data.workspaceId, + type: initialJob.type, + status: 'running', + startedAt: initialJob.startedAt, + updatedAt: initialJob.startedAt, + }) + } + const initialRowCount = data.initialRowCount ?? 0 if (initialRowCount > 0) { const orderKeys = nKeysBetween(null, null, initialRowCount) @@ -526,10 +594,11 @@ export async function createTable( archivedAt: newTable.archivedAt, createdAt: newTable.createdAt, updatedAt: newTable.updatedAt, - importStatus: newTable.importStatus as TableDefinition['importStatus'], - importId: newTable.importId, - importRowsProcessed: 0, - importStartedAt: newTable.importStartedAt, + jobStatus: initialJob ? 'running' : null, + jobId: initialJob?.id ?? null, + jobType: initialJob?.type ?? null, + jobError: null, + jobRowsProcessed: 0, } } @@ -1485,8 +1554,11 @@ async function deleteOrderedRowsByIds(params: { tableId: string workspaceId: string rowIds: string[] + /** Skip the post-delete position recompaction (the paginated delete worker compacts once at + * the end instead of per page — per-page compaction is O(N) each, O(N²) over a full delete). */ + skipCompaction?: boolean }): Promise<{ id: string; position: number }[]> { - const { tableId, workspaceId, rowIds } = params + const { tableId, workspaceId, rowIds, skipCompaction = false } = params if (rowIds.length === 0) return [] return db.transaction(async (trx) => { await setTableTxTimeouts(trx, { statementMs: 60_000 }) @@ -1506,7 +1578,7 @@ async function deleteOrderedRowsByIds(params: { deleted.push(...rows) } // Fractional ordering: deletes leave order_key untouched, so no recompaction. - if (!isTablesFractionalOrderingEnabled && deleted.length > 0) { + if (!isTablesFractionalOrderingEnabled && !skipCompaction && deleted.length > 0) { const minDeletedPos = deleted.reduce( (min, r) => (r.position < min ? r.position : min), deleted[0].position @@ -1517,6 +1589,65 @@ async function deleteOrderedRowsByIds(params: { }) } +/** + * Selects one page of row ids to delete for the async delete-job worker: base scope plus a + * `created_at <= cutoff` floor (so rows inserted after the job started are never selected) and + * the caller's optional filter clause. Keyset paginated on `id` via `afterId` so excluded rows + * (which are skipped, not deleted) still advance the cursor — no OFFSET, no risk of looping on a + * fully-excluded page. + */ +export async function selectRowIdPage(params: { + tableId: string + workspaceId: string + cutoff: Date + filterClause?: SQL + afterId?: string + limit: number +}): Promise { + const { tableId, workspaceId, cutoff, filterClause, afterId, limit } = params + const selectPage = (executor: DbExecutor) => + executor + .select({ id: userTableRows.id }) + .from(userTableRows) + .where( + and( + eq(userTableRows.tableId, tableId), + eq(userTableRows.workspaceId, workspaceId), + lte(userTableRows.createdAt, cutoff), + afterId ? gt(userTableRows.id, afterId) : undefined, + filterClause + ) + ) + .orderBy(asc(userTableRows.id)) + .limit(limit) + // A jsonb filter is unestimatable, so the planner would seq-scan the whole shared relation + // per page (12.6s measured) — keep it on the tenant's (table_id, id) index. + const rows = filterClause + ? await withSeqscanOff(async (trx) => selectPage(trx)) + : await selectPage(db) + return rows.map((r) => r.id) +} + +/** + * Deletes one page of rows by id (the statement-level row_count trigger fires once). Skips legacy + * position compaction: under fractional ordering it's unnecessary (order keys are authoritative), + * and in the legacy path a bulk delete leaving `position` gaps is harmless — rows still order by + * position. (Compacting per page would be O(N²) over a full delete.) Returns the count deleted. + */ +export async function deletePageByIds( + tableId: string, + workspaceId: string, + rowIds: string[] +): Promise { + const deleted = await deleteOrderedRowsByIds({ + tableId, + workspaceId, + rowIds, + skipCompaction: true, + }) + return deleted.length +} + /** * Inserts a single row into a table. * @@ -1853,131 +1984,250 @@ export async function setTableSchemaForImport(tableId: string, schema: TableSche } /** - * Atomically claims a table for an async import. The `import_status != 'importing'` guard makes - * this the single concurrency gate: of two racing kickoffs only one row-update matches, so only - * one wins (no TOCTOU between a separate status check and this write). Returns whether it claimed - * the table — the caller returns 409 when it didn't. + * Atomically claims a table's single background-job slot by inserting a `running` row into + * `table_jobs`. The partial-unique index on `table_id WHERE status = 'running'` is the + * concurrency gate: a second insert while a job runs hits `ON CONFLICT DO NOTHING` and returns no + * row, so import and delete (and two imports) are mutually exclusive for free. Returns whether it + * claimed the slot; the caller returns 409 when it didn't. */ -export async function markTableImporting(tableId: string, importId: string): Promise { - const updated = await db - .update(userTableDefinitions) - .set({ - importStatus: 'importing', - importId, - importError: null, - importRowsProcessed: 0, - importStartedAt: new Date(), - updatedAt: new Date(), +export async function markTableJobRunning( + tableId: string, + jobId: string, + type: TableJobType, + /** Type-specific scope persisted to `table_jobs.payload` (e.g. {@link TableDeleteJobPayload}) + * so read paths can mask the job's effect while it runs. */ + payload?: unknown +): Promise { + // workspace_id is immutable; the atomic gate is the INSERT's conflict, not this read. + const [def] = await db + .select({ workspaceId: userTableDefinitions.workspaceId }) + .from(userTableDefinitions) + .where(eq(userTableDefinitions.id, tableId)) + .limit(1) + if (!def) return false + const inserted = await db + .insert(tableJobs) + .values({ + id: jobId, + tableId, + workspaceId: def.workspaceId, + type, + status: 'running', + payload: payload ?? null, }) - .where( - and( - eq(userTableDefinitions.id, tableId), - or( - isNull(userTableDefinitions.importStatus), - ne(userTableDefinitions.importStatus, 'importing') - ) - ) - ) - .returning({ id: userTableDefinitions.id }) - return updated.length > 0 + .onConflictDoNothing() + .returning({ id: tableJobs.id }) + return inserted.length > 0 } /** - * Releases a claim taken by {@link markTableImporting} for a synchronous import — clears the - * import state back to idle. Scoped to `importId` so it only clears its own claim, never a newer - * run that may have taken over. A sync route claims, writes, then releases here in a `finally`. + * Releases a claim taken by {@link markTableJobRunning} for a synchronous job — deletes the + * transient claim row. Scoped to `jobId` + still-running so it only clears its own claim, never a + * newer run. A sync route claims, writes, then releases here in a `finally`. */ -export async function releaseImportClaim(tableId: string, importId: string): Promise { +export async function releaseJobClaim(tableId: string, jobId: string): Promise { await db - .update(userTableDefinitions) - .set({ importStatus: null, importId: null, importStartedAt: null, updatedAt: new Date() }) + .delete(tableJobs) .where( - and( - eq(userTableDefinitions.id, tableId), - eq(userTableDefinitions.importId, importId), - eq(userTableDefinitions.importStatus, 'importing') - ) + and(eq(tableJobs.id, jobId), eq(tableJobs.tableId, tableId), eq(tableJobs.status, 'running')) ) } /** - * Records import progress (rows processed so far). Also bumps `updatedAt` so the - * stale-import janitor (`cleanup-stale-executions`) sees a live heartbeat and doesn't mark a - * still-running import as failed. + * Records job progress (rows processed so far) and bumps `updated_at` so the stale-job janitor + * (`cleanup-stale-executions`) sees a live heartbeat. * - * Scoped to `importId` AND `import_status = 'importing'`: a stale/superseded worker no longer - * matches (its write is a no-op), and once the import is terminal (e.g. canceled) the match fails - * too — so this returning `false` is also the worker's signal to stop. Returns whether this worker - * still owns an in-flight import. + * Scoped to `jobId` AND `status = 'running'`: a stale/superseded worker no longer matches (its + * write is a no-op), and once the job is terminal (e.g. canceled) the match fails too — so this + * returning `false` is the worker's signal to stop. Returns whether this worker still owns an + * in-flight job. */ -export async function updateImportProgress( +export async function updateJobProgress( tableId: string, rowsProcessed: number, - importId: string + jobId: string ): Promise { const updated = await db - .update(userTableDefinitions) - .set({ importRowsProcessed: rowsProcessed, updatedAt: new Date() }) + .update(tableJobs) + .set({ rowsProcessed, updatedAt: new Date() }) + .where(ownsActiveJob(tableId, jobId)) + .returning({ id: tableJobs.id }) + return updated.length > 0 +} + +/** + * One keyset page of rows for the export worker, ordered by `(position, id)`. Keyset (not + * OFFSET) keeps each page O(page) — offset paging re-scans every prior row per page, which is + * O(N²) across a large export. `(position, id)` is total (position exists on every row; id breaks + * ties) and served by the `(table_id, position)` index; under fractional ordering a manually + * reordered table may export in near-grid rather than exact grid order — the right trade for a + * bulk dump. The delete-job visibility mask applies, like every user-facing read. + */ +export async function selectExportRowPage( + table: TableDefinition, + after: { position: number; id: string } | null, + limit: number +): Promise> { + const deleteMask = await pendingDeleteMask(table) + const rows = await db + .select({ id: userTableRows.id, data: userTableRows.data, position: userTableRows.position }) + .from(userTableRows) .where( and( - eq(userTableDefinitions.id, tableId), - eq(userTableDefinitions.importId, importId), - eq(userTableDefinitions.importStatus, 'importing') + eq(userTableRows.tableId, table.id), + eq(userTableRows.workspaceId, table.workspaceId), + deleteMask, + after + ? sql`(${userTableRows.position}, ${userTableRows.id}) > (${after.position}, ${after.id})` + : undefined ) ) - .returning({ id: userTableDefinitions.id }) - return updated.length > 0 + .orderBy(asc(userTableRows.position), asc(userTableRows.id)) + .limit(limit) + return rows as Array<{ id: string; data: RowData; position: number }> } -/** Shared WHERE for terminal transitions: this import run, and still in-flight (write-once). */ -function ownsActiveImport(tableId: string, importId: string) { +/** How long a terminal export stays listable (and re-downloadable from the tray). */ +const EXPORT_JOB_VISIBILITY_MS = 10 * 60 * 1000 + +export interface WorkspaceExportJob { + jobId: string + tableId: string + tableName: string + status: string + rowsProcessed: number + format: 'csv' | 'json' + hasResult: boolean + error: string | null +} + +/** + * Export jobs the tray surfaces for a workspace: everything running, plus terminals from the last + * {@link EXPORT_JOB_VISIBILITY_MS} so a just-finished export stays re-downloadable. Exports live + * outside the table-level job derivation (which excludes them), so this is their read path. + */ +export async function listWorkspaceExportJobs(workspaceId: string): Promise { + const visibilityCutoff = new Date(Date.now() - EXPORT_JOB_VISIBILITY_MS) + const rows = await db + .select({ + jobId: tableJobs.id, + tableId: tableJobs.tableId, + tableName: userTableDefinitions.name, + status: tableJobs.status, + rowsProcessed: tableJobs.rowsProcessed, + payload: tableJobs.payload, + error: tableJobs.error, + }) + .from(tableJobs) + .innerJoin(userTableDefinitions, eq(userTableDefinitions.id, tableJobs.tableId)) + .where( + and( + eq(tableJobs.workspaceId, workspaceId), + eq(tableJobs.type, 'export'), + or(eq(tableJobs.status, 'running'), gt(tableJobs.updatedAt, visibilityCutoff)) + ) + ) + .orderBy(desc(tableJobs.startedAt)) + return rows.map((r) => { + const payload = r.payload as TableExportJobPayload | null + return { + jobId: r.jobId, + tableId: r.tableId, + tableName: r.tableName, + status: r.status, + rowsProcessed: r.rowsProcessed, + format: payload?.format ?? 'csv', + hasResult: Boolean(payload?.resultKey), + error: r.error, + } + }) +} + +/** Reads one job row (type/status/payload) scoped to its table. Null when absent. */ +export async function getTableJob( + tableId: string, + jobId: string +): Promise<{ id: string; type: string; status: string; payload: unknown } | null> { + const [job] = await db + .select({ + id: tableJobs.id, + type: tableJobs.type, + status: tableJobs.status, + payload: tableJobs.payload, + }) + .from(tableJobs) + .where(and(eq(tableJobs.id, jobId), eq(tableJobs.tableId, tableId))) + .limit(1) + return job ?? null +} + +/** + * Stamps an export job's generated-file storage key onto its payload (`{ resultKey }` merge). + * Scoped to the still-running job so a superseded attempt can't clobber a newer run's result. + * The download route reads it; the janitor deletes the file when the terminal job is pruned. + */ +export async function setJobResultKey( + tableId: string, + jobId: string, + resultKey: string +): Promise { + await db + .update(tableJobs) + .set({ + payload: sql`coalesce(${tableJobs.payload}, '{}'::jsonb) || jsonb_build_object('resultKey', ${resultKey}::text)`, + updatedAt: new Date(), + }) + .where(ownsActiveJob(tableId, jobId)) +} + +/** Shared WHERE for terminal transitions: this job run, and still in-flight (write-once). */ +function ownsActiveJob(tableId: string, jobId: string) { return and( - eq(userTableDefinitions.id, tableId), - eq(userTableDefinitions.importId, importId), - eq(userTableDefinitions.importStatus, 'importing') + eq(tableJobs.id, jobId), + eq(tableJobs.tableId, tableId), + eq(tableJobs.status, 'running') ) } /** - * Marks an import complete; rows become visible. No-op unless it's still this in-flight run. - * Returns whether it transitioned, so the worker only emits the `ready` event when it actually - * won (and not after a cancel / supersede). + * Marks a job complete. No-op unless it's still this in-flight run. Returns whether it + * transitioned, so the worker only emits the `ready` event when it actually won (and not after a + * cancel / supersede). */ -export async function markImportReady(tableId: string, importId: string): Promise { +export async function markJobReady(tableId: string, jobId: string): Promise { + const now = new Date() const updated = await db - .update(userTableDefinitions) - .set({ importStatus: 'ready', importError: null, updatedAt: new Date() }) - .where(ownsActiveImport(tableId, importId)) - .returning({ id: userTableDefinitions.id }) + .update(tableJobs) + .set({ status: 'ready', error: null, completedAt: now, updatedAt: now }) + .where(ownsActiveJob(tableId, jobId)) + .returning({ id: tableJobs.id }) return updated.length > 0 } /** - * Marks an import failed, leaving any already-committed rows in place. No-op unless it's still - * this in-flight run (so a stale worker can't clobber a newer import or a cancel). + * Marks a job failed, leaving any already-committed work in place. No-op unless it's still this + * in-flight run (so a stale worker can't clobber a newer job or a cancel). */ -export async function markImportFailed( - tableId: string, - importId: string, - error: string -): Promise { +export async function markJobFailed(tableId: string, jobId: string, error: string): Promise { + const now = new Date() await db - .update(userTableDefinitions) - .set({ importStatus: 'failed', importError: error.slice(0, 2000), updatedAt: new Date() }) - .where(ownsActiveImport(tableId, importId)) + .update(tableJobs) + .set({ status: 'failed', error: error.slice(0, 2000), completedAt: now, updatedAt: now }) + .where(ownsActiveJob(tableId, jobId)) } /** - * Marks an in-flight import canceled (user-initiated). No-op unless it's still importing. The - * worker's next ownership check then returns `false` and it stops; committed rows are left in - * place (no rollback). Returns whether a running import was actually canceled. + * Marks an in-flight job canceled (user-initiated). No-op unless it's still running. The + * worker's next ownership check then returns `false` and it stops; committed work is left in + * place (no rollback). Returns whether a running job was actually canceled. */ -export async function markImportCanceled(tableId: string, importId: string): Promise { +export async function markJobCanceled(tableId: string, jobId: string): Promise { + const now = new Date() const updated = await db - .update(userTableDefinitions) - .set({ importStatus: 'canceled', updatedAt: new Date() }) - .where(ownsActiveImport(tableId, importId)) - .returning({ id: userTableDefinitions.id }) + .update(tableJobs) + .set({ status: 'canceled', completedAt: now, updatedAt: now }) + .where(ownsActiveJob(tableId, jobId)) + .returning({ id: tableJobs.id }) return updated.length > 0 } @@ -2259,6 +2509,10 @@ export async function upsertRow( // trigger (migration 0198). The update path doesn't change row_count, so no check needed. const result = await db.transaction(async (trx) => { await setTableTxTimeouts(trx) + // The conflict lookups below match on `data->>key` — unestimatable, and an + // insert-path upsert (no existing match) can't exit early, so the planner + // would seq-scan the whole shared relation. See withSeqscanOff. + await trx.execute(sql`SET LOCAL enable_seqscan = off`) // Find existing row by single conflict target column const [existingRow] = await trx @@ -2447,10 +2701,15 @@ const FIND_MATCH_LIMIT = 1000 * reveal it). `filter`/`sort` mirror the active list view via * {@link buildRowOrderBySql}, keeping ordinals aligned. * - * Cost: sequential scan bounded by the `table_id` btree prefix — `ILIKE` over - * `jsonb_each_text` cannot use the JSONB GIN index. Acceptable for tables - * ≪1M rows; a `pg_trgm` GIN index on a text projection is the future - * accelerator if needed. + * Cost: one pass over the table's rows — `ILIKE` over `jsonb_each_text` cannot + * use the JSONB GIN index, and the ordinal's `row_number()` needs every row + * counted regardless. The planner can't estimate the lateral ILIKE (jsonb is + * opaque to it), so left alone it seq-scans the entire shared relation and + * disk-sorts the window input (measured 75s on a 1M-row table in a 12M-row + * relation). `SET LOCAL` planner flags keep it tenant-bounded; on the default + * order they additionally force the streaming `(table_id, order_key, id)` index + * walk where `row_number()` needs no sort at all (measured 2s). A `pg_trgm` GIN + * index on a text projection is the future accelerator if needed. */ export async function findRowMatches( table: TableDefinition, @@ -2463,9 +2722,13 @@ export async function findRowMatches( const columnIds = columns.map(getColumnId) if (columnIds.length === 0) return { matches: [], truncated: false } + // Same visibility rule as queryRows: don't surface rows a running delete job will remove. + const deleteMask = await pendingDeleteMask(table) + const baseConditions = and( eq(userTableRows.tableId, table.id), - eq(userTableRows.workspaceId, table.workspaceId) + eq(userTableRows.workspaceId, table.workspaceId), + deleteMask ) let whereClause: SQL | undefined = baseConditions if (options.filter && Object.keys(options.filter).length > 0) { @@ -2476,24 +2739,39 @@ export async function findRowMatches( const orderBySql = buildRowOrderBySql(options.sort, tableName, columns) const pattern = `%${escapeLikePattern(options.q)}%` - const result = await db.execute<{ - ordinal: string | number - id: string - column_name: string - }>(sql` - WITH ordered AS ( - SELECT id, data, row_number() OVER (ORDER BY ${orderBySql}) - 1 AS ordinal - FROM ${userTableRows} - WHERE ${whereClause} - ) - SELECT o.ordinal, o.id, kv.key AS column_name - FROM ordered o - CROSS JOIN LATERAL jsonb_each_text(o.data) kv - WHERE kv.value ILIKE ${pattern} - AND ${inArray(sql`kv.key`, columnIds)} - ORDER BY o.ordinal - LIMIT ${FIND_MATCH_LIMIT + 1} - `) + const result = await db.transaction(async (trx) => { + // Planner flags, not correctness: `enable_* = off` only penalizes a plan shape, so a + // genuinely required sort still runs. Seqscan off keeps the scan inside the tenant's rows + // (the lateral ILIKE is unestimatable, so the planner otherwise walks the whole shared + // relation). On the default order, the remaining flags steer to the already-sorted + // `(table_id, order_key, id)` index walk so the window function streams without a 100MB+ + // disk sort; a custom sort has no index to stream from, so those flags would only distort + // that plan. + await trx.execute(sql`SET LOCAL enable_seqscan = off`) + if (!options.sort) { + await trx.execute(sql`SET LOCAL enable_bitmapscan = off`) + await trx.execute(sql`SET LOCAL enable_sort = off`) + await trx.execute(sql`SET LOCAL max_parallel_workers_per_gather = 0`) + } + return trx.execute<{ + ordinal: string | number + id: string + column_name: string + }>(sql` + WITH ordered AS ( + SELECT id, data, row_number() OVER (ORDER BY ${orderBySql}) - 1 AS ordinal + FROM ${userTableRows} + WHERE ${whereClause} + ) + SELECT o.ordinal, o.id, kv.key AS column_name + FROM ordered o + CROSS JOIN LATERAL jsonb_each_text(o.data) kv + WHERE kv.value ILIKE ${pattern} + AND ${inArray(sql`kv.key`, columnIds)} + ORDER BY o.ordinal + LIMIT ${FIND_MATCH_LIMIT + 1} + `) + }) const all = Array.from(result) const truncated = all.length > FIND_MATCH_LIMIT @@ -2527,6 +2805,66 @@ export async function findRowMatches( * @param requestId - Request ID for logging * @returns Query result with rows and pagination info */ +/** + * Visibility mask for a running delete job: returns a clause keeping only rows the job will NOT + * delete, or `undefined` when no delete job is running. The job's persisted scope + * ({@link TableDeleteJobPayload}) defines the doomed set — `matches(filter) AND created_at <= + * cutoff AND id NOT IN excludeRowIds` — exactly what the worker's `selectRowIdPage` selects, so + * mid-job reads (refresh, other clients, exports) are consistent with the eventual result. The + * mask lifts automatically when the job leaves `running` (done, failed, or canceled). + * + * `(doomed) IS NOT TRUE` rather than `NOT (doomed)`: JSONB predicates evaluate to NULL on missing + * cells, and those rows are NOT selected for deletion (NULL ≠ TRUE) — they must stay visible. + */ +async function pendingDeleteMask(table: TableDefinition): Promise { + const [job] = await db + .select({ payload: tableJobs.payload }) + .from(tableJobs) + .where( + and( + eq(tableJobs.tableId, table.id), + eq(tableJobs.status, 'running'), + eq(tableJobs.type, 'delete') + ) + ) + .limit(1) + if (!job?.payload) return undefined + const scope = job.payload as TableDeleteJobPayload + + const doomedParts: SQL[] = [] + if (scope.filter && Object.keys(scope.filter).length > 0) { + try { + const clause = buildFilterClause(scope.filter, USER_TABLE_ROWS_SQL_NAME, table.schema.columns) + if (clause) doomedParts.push(clause) + } catch (error) { + // Schema drifted mid-job (column renamed/deleted). Showing doomed rows briefly beats + // failing every read; the worker resolves the same way on its next page. + logger.warn(`Skipping delete-job mask for table ${table.id}: stale filter`, { + error: toError(error).message, + }) + return undefined + } + } + if (scope.cutoff) doomedParts.push(lte(userTableRows.createdAt, new Date(scope.cutoff))) + if (scope.excludeRowIds && scope.excludeRowIds.length > 0) { + doomedParts.push(notInArray(userTableRows.id, scope.excludeRowIds)) + } + if (doomedParts.length === 0) return undefined + return sql`(${and(...doomedParts)}) IS NOT TRUE` +} + +/** + * `COUNT(*)` for a filtered view, kept inside the tenant's rows: measured + * 12.7s → 1.0s counting a rare ILIKE filter on a 1M-row table inside a 12M-row + * relation (see {@link withSeqscanOff} for why the planner gets this wrong). + */ +async function countRowsTenantBounded(whereClause: SQL | undefined): Promise { + return withSeqscanOff(async (trx) => { + const [result] = await trx.select({ count: count() }).from(userTableRows).where(whereClause) + return Number(result.count) + }) +} + export async function queryRows( table: TableDefinition, options: QueryOptions, @@ -2537,6 +2875,7 @@ export async function queryRows( sort, limit = TABLE_LIMITS.DEFAULT_QUERY_LIMIT, offset = 0, + after, includeTotal = true, withExecutions = true, } = options @@ -2544,9 +2883,14 @@ export async function queryRows( const tableName = USER_TABLE_ROWS_SQL_NAME const columns = table.schema.columns + // Hide rows a running delete job is about to remove — both the page and the count below share + // this clause, so totals stay consistent with the visible rows. + const deleteMask = await pendingDeleteMask(table) + const baseConditions = and( eq(userTableRows.tableId, table.id), - eq(userTableRows.workspaceId, table.workspaceId) + eq(userTableRows.workspaceId, table.workspaceId), + deleteMask ) let whereClause = baseConditions @@ -2557,24 +2901,48 @@ export async function queryRows( } } - const query = db - .select() - .from(userTableRows) - .where(whereClause ?? baseConditions) - .orderBy(buildRowOrderBySql(sort, tableName, columns)) + // Keyset page: seek past the cursor on the default `(order_key, id)` order instead of paying + // OFFSET's scan-and-discard of every prior row (O(N²) across a deep scroll / full drain). Only + // valid without a custom sort — the contract rejects `after` + `sort` together. The count below + // deliberately excludes the cursor: totals cover the whole view, not the remaining pages. + const pageWhere = + after && !sort + ? and( + whereClause, + sql`(${userTableRows.orderKey}, ${userTableRows.id}) > (${after.orderKey}, ${after.id})` + ) + : whereClause + + const buildPageQuery = (executor: DbExecutor) => { + const query = executor + .select() + .from(userTableRows) + .where(pageWhere ?? baseConditions) + .orderBy(buildRowOrderBySql(sort, tableName, columns)) + return after ? query.limit(limit) : query.limit(limit).offset(offset) + } // Count and page fetch are independent reads — run them concurrently so the - // `includeTotal` hot path doesn't pay two serial round-trips. - const rowsPromise = query.limit(limit).offset(offset) + // `includeTotal` hot path doesn't pay two serial round-trips. Filtered counts + // go through the tenant-bounded variant (see countRowsTenantBounded); the + // unfiltered count already plans an index-only scan on the table_id prefix. + // Custom column sorts order by `data->>'col'` — unestimatable, so left alone + // the planner seq-scans and sorts the whole shared relation on every page + // (9.7s measured on a 1M-row table; 0.76s tenant-bounded). Default-order + // pages already stream the `(table_id, order_key, id)` index. + const hasFilter = Boolean(filter && Object.keys(filter).length > 0) + const rowsPromise = sort ? withSeqscanOff(async (trx) => buildPageQuery(trx)) : buildPageQuery(db) const countPromise = includeTotal - ? db - .select({ count: count() }) - .from(userTableRows) - .where(whereClause ?? baseConditions) + ? hasFilter + ? countRowsTenantBounded(whereClause) + : db + .select({ count: count() }) + .from(userTableRows) + .where(whereClause ?? baseConditions) + .then((r) => Number(r[0].count)) : null - const [rows, countResult] = await Promise.all([rowsPromise, countPromise]) - const totalCount = countResult ? Number(countResult[0].count) : null + const [rows, totalCount] = await Promise.all([rowsPromise, countPromise]) const executionsByRow = withExecutions ? await loadExecutionsByRow( @@ -3112,16 +3480,18 @@ export async function updateRowsByFilter( eq(userTableRows.workspaceId, table.workspaceId) ) - let query = db - .select({ id: userTableRows.id, data: userTableRows.data }) - .from(userTableRows) - .where(and(baseConditions, filterClause)) - - if (data.limit) { - query = query.limit(data.limit) as typeof query - } - - const matchingRows = await query + // Tenant-bounded: the jsonb filter is unestimatable and otherwise sends the planner to a + // whole-shared-relation seq scan (14.4s measured on a 1M-row table). + const matchingRows = await withSeqscanOff(async (trx) => { + let query = trx + .select({ id: userTableRows.id, data: userTableRows.data }) + .from(userTableRows) + .where(and(baseConditions, filterClause)) + if (data.limit) { + query = query.limit(data.limit) as typeof query + } + return query + }) if (matchingRows.length === 0) { return { affectedCount: 0, affectedRowIds: [] } @@ -3453,16 +3823,17 @@ export async function deleteRowsByFilter( eq(userTableRows.workspaceId, table.workspaceId) ) - let query = db - .select({ id: userTableRows.id, position: userTableRows.position }) - .from(userTableRows) - .where(and(baseConditions, filterClause)) - - if (data.limit) { - query = query.limit(data.limit) as typeof query - } - - const matchingRows = await query + // Tenant-bounded for the same reason as updateRowsByFilter — see withSeqscanOff. + const matchingRows = await withSeqscanOff(async (trx) => { + let query = trx + .select({ id: userTableRows.id, position: userTableRows.position }) + .from(userTableRows) + .where(and(baseConditions, filterClause)) + if (data.limit) { + query = query.limit(data.limit) as typeof query + } + return query + }) if (matchingRows.length === 0) { return { affectedCount: 0, affectedRowIds: [] } @@ -4416,12 +4787,14 @@ export async function updateWorkflowGroup( // - remapped outputs (existing column re-pointed): overwrite, since the // new mapping is the source of truth and the user expects the cell to // refresh to the new output's value. - // Awaited so the response only returns once row data is consistent. A - // failed backfill is logged but doesn't fail the request — the schema - // change has already committed. + // Small tables backfill inline-awaited (response returns with consistent + // data); large ones run as a background job. A failed backfill is logged + // but doesn't fail the request — the schema change has already committed. + // Lazy import: backfill-runner closes a cycle back to this module. + const { maybeBackfillGroupOutputs } = await import('./backfill-runner') if (added.length > 0) { try { - await backfillGroupOutputsFromLogs({ + await maybeBackfillGroupOutputs({ table: updatedTable, groupId: data.groupId, outputs: added, @@ -4439,7 +4812,7 @@ export async function updateWorkflowGroup( if (remappedColumnIds.size > 0) { const remappedOutputs = newOutputs.filter((o) => remappedColumnIds.has(o.columnName)) try { - await backfillGroupOutputsFromLogs({ + await maybeBackfillGroupOutputs({ table: updatedTable, groupId: data.groupId, outputs: remappedOutputs, @@ -4697,8 +5070,11 @@ export async function addWorkflowGroupOutput( // Cheap compared to re-running the workflow on every row, which is what // an earlier version of this code did — that mistakenly fanned out N // workflow-group-cell jobs and burned compute the user didn't ask for. + // Small tables backfill inline; large ones run as a background job. + // Lazy import: backfill-runner closes a cycle back to this module. try { - await backfillGroupOutputsFromLogs({ + const { maybeBackfillGroupOutputs } = await import('./backfill-runner') + await maybeBackfillGroupOutputs({ table: updatedTable, groupId: data.groupId, outputs: [newOutput], @@ -4849,152 +5225,6 @@ export async function deleteWorkflowGroup( }) } -/** Minimal shape of a trace span we care about for backfill. */ -interface BackfillTraceSpan { - blockId?: string - output?: Record - children?: BackfillTraceSpan[] -} - -/** DFS the trace tree for the first span matching `blockId`. */ -function findSpanByBlockId( - spans: BackfillTraceSpan[] | undefined, - blockId: string -): BackfillTraceSpan | undefined { - if (!spans) return undefined - for (const span of spans) { - if (span.blockId === blockId) return span - const child = findSpanByBlockId(span.children, blockId) - if (child) return child - } - return undefined -} - -/** - * Walks completed group executions and pulls each target output's value out of - * the workflow's saved trace spans, writing it back into row data. Used in two - * spots: - * - * - **added** outputs (new columns added to an existing group): `overwrite` - * is false, so rows with a hand-edited value already in the column are - * left alone. - * - **remapped** outputs (existing column re-pointed at a different - * `(blockId, path)`): `overwrite` is true — the new mapping is the source - * of truth, and the user expects the column to refresh to the new - * output's value rather than retain the stale old one. - */ -async function backfillGroupOutputsFromLogs(opts: { - table: TableDefinition - groupId: string - outputs: WorkflowGroupOutput[] - overwrite: boolean - requestId: string - actorUserId?: string | null -}): Promise { - const { table, groupId, outputs, overwrite, requestId, actorUserId } = opts - if (outputs.length === 0) return - - const { pluckByPath } = await import('./pluck') - - // Find rows whose group execution completed and grab their executionId - // directly from the sidecar — hits the (table_id, group_id) index, no - // table scan over rowdata. - const completedExecs = await db - .select({ - rowId: tableRowExecutions.rowId, - executionId: tableRowExecutions.executionId, - }) - .from(tableRowExecutions) - .where( - and( - eq(tableRowExecutions.tableId, table.id), - eq(tableRowExecutions.groupId, groupId), - eq(tableRowExecutions.status, 'completed') - ) - ) - - const executionIdsByRow = new Map() - for (const e of completedExecs) { - if (!e.executionId) continue - executionIdsByRow.set(e.rowId, e.executionId) - } - if (executionIdsByRow.size === 0) return - - const rowRecords = await db - .select({ id: userTableRows.id, data: userTableRows.data }) - .from(userTableRows) - .where( - and( - eq(userTableRows.tableId, table.id), - inArray(userTableRows.id, Array.from(executionIdsByRow.keys())) - ) - ) - - const executionIds = Array.from(new Set(executionIdsByRow.values())) - const logs = await db - .select({ - executionId: workflowExecutionLogs.executionId, - workflowId: workflowExecutionLogs.workflowId, - workspaceId: workflowExecutionLogs.workspaceId, - executionData: workflowExecutionLogs.executionData, - }) - .from(workflowExecutionLogs) - .where(inArray(workflowExecutionLogs.executionId, executionIds)) - - const logByExecutionId = new Map() - // Heavy execution data may live in object storage; resolve pointers (bounded - // concurrency) so trace spans are available for table-column enrichment. - await mapWithConcurrency(logs, MATERIALIZE_CONCURRENCY, async (log) => { - const executionData = await materializeExecutionData( - log.executionData as Record | null, - { workspaceId: log.workspaceId, workflowId: log.workflowId, executionId: log.executionId } - ) - logByExecutionId.set( - log.executionId, - (executionData as { traceSpans?: BackfillTraceSpan[] }) ?? {} - ) - }) - - const updates: Array<{ rowId: string; data: RowData }> = [] - for (const r of rowRecords) { - const execId = executionIdsByRow.get(r.id) - if (!execId) continue - const log = logByExecutionId.get(execId) - if (!log) continue - - const dataPatch: RowData = {} - let mutated = false - for (const out of outputs) { - if (!overwrite && (r.data as RowData)[out.columnName] !== undefined) continue - const span = findSpanByBlockId(log.traceSpans, out.blockId) - if (!span?.output) continue - const picked = pluckByPath(span.output, out.path) - if (picked === undefined) continue - dataPatch[out.columnName] = picked as RowData[string] - mutated = true - } - if (!mutated) continue - updates.push({ rowId: r.id, data: dataPatch }) - } - - if (updates.length === 0) return - - await batchUpdateRows( - { - tableId: table.id, - updates, - workspaceId: table.workspaceId, - actorUserId, - }, - table, - requestId - ) - - logger.info( - `[${requestId}] Backfilled ${updates.length} row(s) for group "${groupId}" in table ${table.id} (${overwrite ? 'remapped' : 'added'})` - ) -} - /** * Checks if a value is compatible with a target column type. */ diff --git a/apps/sim/lib/table/types.ts b/apps/sim/lib/table/types.ts index df36bc8f465..691b5925986 100644 --- a/apps/sim/lib/table/types.ts +++ b/apps/sim/lib/table/types.ts @@ -191,8 +191,57 @@ export interface TableMetadata { pinnedColumns?: string[] } -/** Async-import lifecycle state for a table. NULL/undefined = normal (no async import). */ -export type TableImportStatus = 'importing' | 'ready' | 'failed' | 'canceled' +/** Async background-job lifecycle state for a table. NULL/undefined = idle (no job). */ +export type TableJobStatus = 'running' | 'ready' | 'failed' | 'canceled' + +/** + * Which kind of background job a `table_jobs` row tracks. `import`, `delete`, and `backfill` + * mutate row data and share the single-running-job gate; `export` is read-only and bypasses it + * (the partial-unique index excludes it), so an export can run alongside any other job. + */ +export type TableJobType = 'import' | 'delete' | 'export' | 'backfill' + +/** + * Persisted scope of a running delete job (`table_jobs.payload`). Defines the doomed row set — + * `matches(filter) AND created_at <= cutoff AND id NOT IN excludeRowIds` — so the rows read-path + * can mask those rows out while the job runs, making mid-job reads (refresh, other clients) + * consistent with the eventual result. + */ +export interface TableDeleteJobPayload { + filter?: Filter + excludeRowIds?: string[] + /** ISO timestamp; rows created after it are spared. */ + cutoff: string +} + +/** + * Persisted scope of an export job (`table_jobs.payload`). `resultKey` is merged in by the worker + * on completion — the storage key of the generated file, served to the client via a presigned URL + * and deleted by the janitor when the terminal job is pruned. + */ +export interface TableExportJobPayload { + format: 'csv' | 'json' + resultKey?: string +} + +/** + * Keyset cursor for paginating a table's default row order, `(order_key, id)`. The grid's + * infinite scroll threads this instead of an OFFSET — offset paging re-scans every prior row per + * page (O(N²) to drain a table); the cursor makes each page an index seek on + * `(table_id, order_key, id)`. Only valid for the default order: sorted views fall back to offset. + */ +export interface TableRowsCursor { + orderKey: string + id: string +} + +/** Persisted scope of an output-column backfill job (`table_jobs.payload`). */ +export interface TableBackfillJobPayload { + groupId: string + outputs: WorkflowGroupOutput[] + /** Remaps overwrite existing cell values; added columns never clobber hand-edits. */ + overwrite: boolean +} export interface TableDefinition { id: string @@ -207,12 +256,15 @@ export interface TableDefinition { archivedAt?: Date | string | null createdAt: Date | string updatedAt: Date | string - /** Async-import state (see `apps/sim/lib/table/import-runner.ts`). */ - importStatus?: TableImportStatus | null - importId?: string | null - importError?: string | null - importRowsProcessed?: number - importStartedAt?: Date | string | null + /** + * Async background-job state, derived from the table's latest `table_jobs` row (running if any, + * else the most recent terminal). See `import-runner.ts` / `delete-runner.ts`. + */ + jobStatus?: TableJobStatus | null + jobId?: string | null + jobType?: TableJobType | null + jobError?: string | null + jobRowsProcessed?: number } /** Minimal table info for UI components. */ @@ -316,6 +368,9 @@ export interface QueryOptions { sort?: Sort limit?: number offset?: number + /** Keyset cursor for the default `(order_key, id)` order — see {@link TableRowsCursor}. + * Mutually exclusive with `sort` and `offset`; takes precedence over `offset` when set. */ + after?: TableRowsCursor /** * When true (default), runs a `COUNT(*)` and returns `totalCount` as a number. * Pass `false` to skip the count query (grid UI doesn't need it); `totalCount` @@ -355,10 +410,12 @@ export interface CreateTableData { maxTables?: number /** Number of empty rows to create with the table. Defaults to 0. */ initialRowCount?: number - /** When set, the table is created in this async-import state (rows hidden until ready). */ - importStatus?: TableImportStatus - /** Async-import id stamped on the table when `importStatus` is set. */ - importId?: string + /** When set, the table is created with this job already running (rows hidden until ready). */ + jobStatus?: TableJobStatus + /** Job kind, paired with `jobStatus` (create-mode import sets `'import'`). */ + jobType?: TableJobType + /** Async job id stamped on the table when `jobStatus` is set. */ + jobId?: string } export interface InsertRowData { diff --git a/apps/sim/lib/table/validation.ts b/apps/sim/lib/table/validation.ts index c5d61e19f42..bbec05f1687 100644 --- a/apps/sim/lib/table/validation.ts +++ b/apps/sim/lib/table/validation.ts @@ -4,10 +4,11 @@ import { db } from '@sim/db' import { userTableRows } from '@sim/db/schema' -import { and, eq, or, sql } from 'drizzle-orm' +import { and, eq, or, type SQL, sql } from 'drizzle-orm' import { NextResponse } from 'next/server' import { getColumnId } from './column-keys' import { COLUMN_TYPES, NAME_PATTERN, TABLE_LIMITS } from './constants' +import { withSeqscanOff } from './planner' import type { ColumnDefinition, JsonValue, RowData, TableSchema, ValidationResult } from './types' export type { ColumnDefinition, TableSchema, ValidationResult } @@ -420,7 +421,7 @@ export async function checkUniqueConstraintsDb( } // Build conditions for each unique column value - const conditions = [] + const conditions: Array<{ column: ColumnDefinition; value: unknown; sql: SQL }> = [] for (const column of uniqueColumns) { const key = getColumnId(column) @@ -451,26 +452,31 @@ export async function checkUniqueConstraintsDb( return { valid: true, errors: [] } } - // Query for each unique column separately to provide specific error messages - for (const condition of conditions) { - const baseCondition = and(eq(userTableRows.tableId, tableId), condition.sql) + // Query for each unique column separately to provide specific error messages. + // Tenant-bounded: `lower(data->>'col') = ...` is unestimatable, so the planner + // otherwise seq-scans the whole shared relation per check — 3.5s on every + // insert/edit when the value is unique (no early exit). See withSeqscanOff. + await withSeqscanOff(async (trx) => { + for (const condition of conditions) { + const baseCondition = and(eq(userTableRows.tableId, tableId), condition.sql) - const whereClause = excludeRowId - ? and(baseCondition, sql`${userTableRows.id} != ${excludeRowId}`) - : baseCondition + const whereClause = excludeRowId + ? and(baseCondition, sql`${userTableRows.id} != ${excludeRowId}`) + : baseCondition - const conflictingRow = await db - .select({ id: userTableRows.id, position: userTableRows.position }) - .from(userTableRows) - .where(whereClause) - .limit(1) + const conflictingRow = await trx + .select({ id: userTableRows.id, position: userTableRows.position }) + .from(userTableRows) + .where(whereClause) + .limit(1) - if (conflictingRow.length > 0) { - errors.push( - `Column "${condition.column.name}" must be unique. Value "${condition.value}" already exists in row ${conflictingRow[0].position + 1}` - ) + if (conflictingRow.length > 0) { + errors.push( + `Column "${condition.column.name}" must be unique. Value "${condition.value}" already exists in row ${conflictingRow[0].position + 1}` + ) + } } - } + }) return { valid: errors.length === 0, errors } } @@ -480,7 +486,7 @@ export async function checkUniqueConstraintsDb( * drizzle transaction (`trx`) satisfy this, letting callers run the lookup * inside an open transaction so it observes uncommitted prior-batch inserts. */ -type UniqueCheckExecutor = Pick +type UniqueCheckExecutor = Pick /** * Checks unique constraints for a batch of rows using targeted database queries. @@ -548,70 +554,84 @@ export async function checkBatchUniqueConstraintsDb( } } - // Now check against database for all unique values at once - for (const [columnId, { values, column }] of valuesByColumn) { - if (values.size === 0) continue - - if (!NAME_PATTERN.test(columnId)) { - throw new Error(`Invalid column id: ${columnId}`) - } - - const valueArray = Array.from(values) - const valueConditions = valueArray.map((normalizedValue) => { - // Check if the original values are strings (normalized values for strings are lowercase) - // We need to determine the type from the column definition or the first row that has this value - const isStringColumn = column.type === 'string' + // Now check against database for all unique values at once. Tenant-bounded + // for the same reason as checkUniqueConstraintsDb: the lower(data->>...) + // predicates are unestimatable and otherwise trigger whole-relation seq + // scans. With an external transaction the flag is set on it directly (SET + // LOCAL dies at its commit; it only penalizes plan shape, and the statements + // that follow in those transactions are tenant-scoped writes). + const checkColumns = async (ex: UniqueCheckExecutor) => { + for (const [columnId, { values, column }] of valuesByColumn) { + if (values.size === 0) continue - if (isStringColumn) { - return sql`lower(${userTableRows.data}->>${sql.raw(`'${columnId}'`)}) = ${normalizedValue}` + if (!NAME_PATTERN.test(columnId)) { + throw new Error(`Invalid column id: ${columnId}`) } - return sql`(${userTableRows.data}->${sql.raw(`'${columnId}'`)})::jsonb = ${normalizedValue}::jsonb` - }) - const conflictingRows = await executor - .select({ - id: userTableRows.id, - data: userTableRows.data, - position: userTableRows.position, + const valueArray = Array.from(values) + const valueConditions = valueArray.map((normalizedValue) => { + // Check if the original values are strings (normalized values for strings are lowercase) + // We need to determine the type from the column definition or the first row that has this value + const isStringColumn = column.type === 'string' + + if (isStringColumn) { + return sql`lower(${userTableRows.data}->>${sql.raw(`'${columnId}'`)}) = ${normalizedValue}` + } + return sql`(${userTableRows.data}->${sql.raw(`'${columnId}'`)})::jsonb = ${normalizedValue}::jsonb` }) - .from(userTableRows) - .where(and(eq(userTableRows.tableId, tableId), or(...valueConditions))) - .limit(valueArray.length) // We only need up to one conflict per value - - // Map conflicts back to batch rows - for (const conflict of conflictingRows) { - const conflictData = conflict.data as RowData - const conflictValue = conflictData[columnId] - const normalizedConflictValue = - typeof conflictValue === 'string' - ? conflictValue.toLowerCase() - : JSON.stringify(conflictValue) - - // Find which batch rows have this conflicting value - for (let i = 0; i < rows.length; i++) { - const rowValue = rows[i][columnId] - if (rowValue === null || rowValue === undefined) continue - - const normalizedRowValue = - typeof rowValue === 'string' ? rowValue.toLowerCase() : JSON.stringify(rowValue) - - if (normalizedRowValue === normalizedConflictValue) { - // Check if this row already has errors for this column - let rowError = rowErrors.find((e) => e.row === i) - if (!rowError) { - rowError = { row: i, errors: [] } - rowErrors.push(rowError) - } - const errorMsg = `Column "${column.name}" must be unique. Value "${rowValue}" already exists in row ${conflict.position + 1}` - if (!rowError.errors.includes(errorMsg)) { - rowError.errors.push(errorMsg) + const conflictingRows = await ex + .select({ + id: userTableRows.id, + data: userTableRows.data, + position: userTableRows.position, + }) + .from(userTableRows) + .where(and(eq(userTableRows.tableId, tableId), or(...valueConditions))) + .limit(valueArray.length) // We only need up to one conflict per value + + // Map conflicts back to batch rows + for (const conflict of conflictingRows) { + const conflictData = conflict.data as RowData + const conflictValue = conflictData[columnId] + const normalizedConflictValue = + typeof conflictValue === 'string' + ? conflictValue.toLowerCase() + : JSON.stringify(conflictValue) + + // Find which batch rows have this conflicting value + for (let i = 0; i < rows.length; i++) { + const rowValue = rows[i][columnId] + if (rowValue === null || rowValue === undefined) continue + + const normalizedRowValue = + typeof rowValue === 'string' ? rowValue.toLowerCase() : JSON.stringify(rowValue) + + if (normalizedRowValue === normalizedConflictValue) { + // Check if this row already has errors for this column + let rowError = rowErrors.find((e) => e.row === i) + if (!rowError) { + rowError = { row: i, errors: [] } + rowErrors.push(rowError) + } + + const errorMsg = `Column "${column.name}" must be unique. Value "${rowValue}" already exists in row ${conflict.position + 1}` + if (!rowError.errors.includes(errorMsg)) { + rowError.errors.push(errorMsg) + } } } } } } + if (executor === db) { + await withSeqscanOff(async (trx) => checkColumns(trx)) + } else { + await executor.execute(sql`SET LOCAL enable_seqscan = off`) + await checkColumns(executor) + } + // Sort errors by row index rowErrors.sort((a, b) => a.row - b.row) diff --git a/apps/sim/lib/table/workflow-columns.ts b/apps/sim/lib/table/workflow-columns.ts index ed5379fbe51..0e320ac7ad0 100644 --- a/apps/sim/lib/table/workflow-columns.ts +++ b/apps/sim/lib/table/workflow-columns.ts @@ -6,15 +6,20 @@ */ import { db } from '@sim/db' -import { pausedExecutions, tableRowExecutions, type userTableRows } from '@sim/db/schema' +import { + pausedExecutions, + tableRowExecutions, + userTableRows as userTableRowsTable, +} from '@sim/db/schema' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' -import { and, eq, inArray, sql } from 'drizzle-orm' +import { and, eq, inArray, notInArray, sql } from 'drizzle-orm' import type { EnqueueOptions } from '@/lib/core/async-jobs/types' import { isTriggerDevEnabled } from '@/lib/core/config/feature-flags' import { buildCancelledExecution } from '@/lib/table/cell-write' import type { + Filter, RowData, RowExecutionMetadata, RowExecutions, @@ -27,8 +32,10 @@ import type { const logger = createLogger('WorkflowGroupScheduler') import { getColumnId } from './column-keys' +import { USER_TABLE_ROWS_SQL_NAME } from './constants' import { areGroupDepsSatisfied, areOutputsFilled, isExecInFlight } from './deps' import type { DispatchLimit, DispatchMode } from './dispatcher' +import { buildFilterClause } from './sql' export { getUnmetGroupDeps, @@ -306,7 +313,7 @@ export async function cancelCellRunsByTags(tags: string[]): Promise { } export function toTableRow( - r: typeof userTableRows.$inferSelect, + r: typeof userTableRowsTable.$inferSelect, executions: RowExecutions = {} ): TableRow { return { @@ -350,12 +357,15 @@ export const TABLE_CONCURRENCY_LIMIT = 20 * whether the trigger.dev cancel reaches the worker before its terminal * write. Pass `groupIds` to restrict the cancel to a subset of groups on * the row (used by `updateRow` to cancel only the downstream groups whose - * deps just changed). + * deps just changed). Pass `filter` (table-wide form only) to cancel just + * the cells on rows matching it — a filtered "select all" must not stop + * work on rows outside its scope, so like the per-row form it leaves + * active dispatches running (their in-flight checks skip cancelled cells). */ export async function cancelWorkflowGroupRuns( tableId: string, rowId?: string, - options?: { groupIds?: string[] } + options?: { groupIds?: string[]; filter?: Filter; excludeRowIds?: string[] } ): Promise { const { getTableById, updateRow } = await import('@/lib/table/service') const { getJobQueue } = await import('@/lib/core/async-jobs/config') @@ -370,8 +380,11 @@ export async function cancelWorkflowGroupRuns( // Per-row cancel leaves the dispatcher alone — other rows in the same // dispatch keep running. Table-wide cancel must stop it, else the cursor // marches on and re-enqueues fresh cells past what we just cancelled. + // Filter-scoped cancel stops only dispatches with that exact filter scope + // (its own run); whole-table or differently-scoped dispatches keep running — + // their cells cancelled below are skipped via `cancelledAt > requestedAt`. if (!rowId) { - await markActiveDispatchesCancelled(tableId) + await markActiveDispatchesCancelled(tableId, options?.filter) } const allGroups = table.schema.workflowGroups ?? [] @@ -422,6 +435,30 @@ export async function cancelWorkflowGroupRuns( ] if (rowId) { inFlightFilters.push(eq(tableRowExecutions.rowId, rowId)) + } else if (options?.excludeRowIds?.length) { + // Select-all minus deselections: the deselected rows' cells keep running. + inFlightFilters.push(notInArray(tableRowExecutions.rowId, options.excludeRowIds)) + } + if (!rowId && options?.filter) { + // Filter-scoped cancel: only cells on rows matching the filter. Semi-join + // against the tenant's rows — the in-flight sidecar set is small, so the + // jsonb predicate is evaluated on few rows. + const filterClause = buildFilterClause( + options.filter, + USER_TABLE_ROWS_SQL_NAME, + table.schema.columns + ) + if (filterClause) { + inFlightFilters.push( + inArray( + tableRowExecutions.rowId, + db + .select({ id: userTableRowsTable.id }) + .from(userTableRowsTable) + .where(and(eq(userTableRowsTable.tableId, tableId), filterClause)) + ) + ) + } } const inFlightRows = await db .select() @@ -587,6 +624,12 @@ export async function runWorkflowColumn(opts: { requestId: string groupIds?: string[] rowIds?: string[] + /** "Select all under a filter" — run every row matching this filter (mutually exclusive with + * `rowIds`). Threaded into the dispatch scope so the dispatcher walks only matching rows. */ + filter?: Filter + /** Select-all scope only: deselected rows — the dispatcher walk, eager clear, and pre-run + * cancel all skip them. */ + excludeRowIds?: string[] /** Optional cap on work before the dispatch completes (e.g. run only the * first N eligible rows). Null/omitted = process every row in scope. */ limit?: DispatchLimit | null @@ -599,7 +642,18 @@ export async function runWorkflowColumn(opts: { * account at billing time. */ triggeredByUserId?: string | null }): Promise<{ dispatchId: string | null }> { - const { tableId, workspaceId, mode, requestId, groupIds, rowIds, limit, triggeredByUserId } = opts + const { + tableId, + workspaceId, + mode, + requestId, + groupIds, + rowIds, + filter, + excludeRowIds, + limit, + triggeredByUserId, + } = opts const isManualRun = opts.isManualRun ?? true // Empty `rowIds` array means "scope explicitly empty" — auto-fire callers // (CSV import on zero matches, etc.) end up here. Skip the dispatch entirely @@ -641,7 +695,13 @@ export async function runWorkflowColumn(opts: { const cancelPriorRuns = isManualRun && (mode === 'all' || mode === 'incomplete') if (cancelPriorRuns) { if (!rowIds || rowIds.length === 0) { - await cancelWorkflowGroupRuns(tableId, undefined, { groupIds: targetGroupIds }) + // Filtered runs cancel only their own scope — a table-wide cancel here + // would stop unrelated work on rows outside the filter (or on deselected rows). + await cancelWorkflowGroupRuns(tableId, undefined, { + groupIds: targetGroupIds, + filter, + excludeRowIds, + }) } else { // Per-row cancel — sequential so we don't fan out N parallel // markActiveDispatchesCancelled calls (it's a no-op when rowId is set, @@ -659,11 +719,15 @@ export async function runWorkflowColumn(opts: { // rows in scope would blank far more than we re-run. `mode: 'all'` re-runs // completed cells without the clear anyway — the clear is only for instant // feedback, which the capped rows still get via the dispatcher's pre-stamp. - if (!limit) { + // Skip the eager clear for a filtered run: `bulkClearWorkflowGroupCells` keys by `rowIds`, and a + // filtered scope has none — clearing table-wide would blank rows that don't match the filter. The + // dispatcher's per-row pre-stamp still provides instant Pending feedback as it walks. + if (!limit && !filter) { await bulkClearWorkflowGroupCells({ tableId, groups: targetGroups.map((g) => ({ id: g.id, outputs: g.outputs })), rowIds, + excludeRowIds, mode, }) } @@ -680,6 +744,10 @@ export async function runWorkflowColumn(opts: { scope: { groupIds: targetGroupIds, ...(rowIds && rowIds.length > 0 ? { rowIds } : {}), + ...(filter ? { filter } : {}), + ...(excludeRowIds && excludeRowIds.length > 0 && !(rowIds && rowIds.length > 0) + ? { excludeRowIds } + : {}), }, limit, isManualRun, diff --git a/apps/sim/stores/table/import-tray/store.ts b/apps/sim/stores/table/import-tray/store.ts index 66be5080894..174485acb61 100644 --- a/apps/sim/stores/table/import-tray/store.ts +++ b/apps/sim/stores/table/import-tray/store.ts @@ -25,6 +25,12 @@ interface ImportTrayState { notified: Record /** Ids (upload or table) canceled so callbacks/derivation don't resurrect them. */ canceledIds: Record + /** + * Server-listed rows the user dismissed (export jobIds). Unlike `notified` — an allow-list for + * table-derived terminals — export terminals are listed by the server for a visibility window, + * so dismissal needs a deny-list. + */ + dismissedIds: Record menuOpen: boolean startUpload: (upload: ImportUpload) => void @@ -34,6 +40,8 @@ interface ImportTrayState { notify: (tableId: string) => void /** Remove a terminal card (manual dismiss or auto-clear). */ dismiss: (tableId: string) => void + /** Hide a server-listed job row (export) for the rest of the session. */ + dismissJob: (jobId: string) => void /** Flag an id canceled and drop any optimistic upload for it. */ cancel: (id: string) => void isCanceled: (id: string) => boolean @@ -47,6 +55,7 @@ const initialState = { uploads: {} as Record, notified: {} as Record, canceledIds: {} as Record, + dismissedIds: {} as Record, menuOpen: false, } @@ -81,6 +90,9 @@ export const useImportTrayStore = create()( return { notified: rest } }), + dismissJob: (jobId) => + set((state) => ({ dismissedIds: { ...state.dismissedIds, [jobId]: true } })), + cancel: (id) => set((state) => { const { [id]: _removed, ...uploads } = state.uploads diff --git a/packages/db/migrations/0231_table_jobs_and_keyset.sql b/packages/db/migrations/0231_table_jobs_and_keyset.sql new file mode 100644 index 00000000000..edb133a1188 --- /dev/null +++ b/packages/db/migrations/0231_table_jobs_and_keyset.sql @@ -0,0 +1,55 @@ +CREATE TABLE "table_jobs" ( + "id" text PRIMARY KEY NOT NULL, + "table_id" text NOT NULL, + "workspace_id" text NOT NULL, + "type" text NOT NULL, + "status" text DEFAULT 'running' NOT NULL, + "payload" jsonb, + "rows_processed" integer DEFAULT 0 NOT NULL, + "error" text, + "started_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL, + "completed_at" timestamp +); +--> statement-breakpoint +ALTER TABLE "table_jobs" ADD CONSTRAINT "table_jobs_table_id_user_table_definitions_id_fk" FOREIGN KEY ("table_id") REFERENCES "public"."user_table_definitions"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "table_jobs" ADD CONSTRAINT "table_jobs_workspace_id_workspace_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspace"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE UNIQUE INDEX "table_jobs_one_active_per_table" ON "table_jobs" USING btree ("table_id") WHERE "table_jobs"."status" = 'running' AND "table_jobs"."type" <> 'export';--> statement-breakpoint +CREATE INDEX "table_jobs_watchdog_idx" ON "table_jobs" USING btree ("status","updated_at");--> statement-breakpoint +CREATE INDEX "table_jobs_table_started_idx" ON "table_jobs" USING btree ("table_id","started_at");--> statement-breakpoint +CREATE INDEX "user_table_rows_table_id_id_idx" ON "user_table_rows" USING btree ("table_id","id");--> statement-breakpoint +-- Migrate any existing import-job state off the definition columns into table_jobs before dropping +-- them. Each definition has at most one import_status, so this yields at most one job per table and +-- never violates the partial-unique active-job index. The old in-flight literal 'importing' maps to +-- 'running'. +INSERT INTO "table_jobs" ("id", "table_id", "workspace_id", "type", "status", "rows_processed", "error", "started_at", "updated_at", "completed_at") +SELECT + COALESCE("import_id", 'job_' || "id"), + "id", + "workspace_id", + 'import', + CASE WHEN "import_status" = 'importing' THEN 'running' ELSE "import_status" END, + COALESCE("import_rows_processed", 0), + "import_error", + COALESCE("import_started_at", now()), + now(), + CASE WHEN "import_status" IN ('ready', 'failed', 'canceled') THEN now() ELSE NULL END +FROM "user_table_definitions" +WHERE "import_status" IS NOT NULL;--> statement-breakpoint +ALTER TABLE "user_table_definitions" DROP COLUMN "import_status";--> statement-breakpoint +ALTER TABLE "user_table_definitions" DROP COLUMN "import_id";--> statement-breakpoint +ALTER TABLE "user_table_definitions" DROP COLUMN "import_error";--> statement-breakpoint +ALTER TABLE "user_table_definitions" DROP COLUMN "import_rows_processed";--> statement-breakpoint +ALTER TABLE "user_table_definitions" DROP COLUMN "import_started_at";--> statement-breakpoint +-- All tenants' rows share this one relation, so the default autovacuum trigger (~20% of the whole +-- relation dead) lets a single tenant's mass delete leave their reads degraded for days. Vacuum at +-- ~2% churn instead; runs are frequent but cheap (the visibility map skips untouched pages). +ALTER TABLE "user_table_rows" SET (autovacuum_vacuum_scale_factor = 0.02, autovacuum_analyze_scale_factor = 0.01);--> statement-breakpoint +-- Tenant-scoped containment index: a plain GIN on data matches @> candidates across every +-- tenant sharing this relation (measured 1.07M candidates fetched for a 33k-row match). btree_gin +-- lets table_id lead the GIN so the intersection happens inside the index, and jsonb_path_ops +-- indexes whole key->value paths (single lookup, smaller index). Every @> query carries table_id, +-- so the old cross-tenant index is strictly redundant. +CREATE EXTENSION IF NOT EXISTS btree_gin;--> statement-breakpoint +DROP INDEX "user_table_rows_data_gin_idx";--> statement-breakpoint +CREATE INDEX "user_table_rows_tenant_data_gin_idx" ON "user_table_rows" USING gin ("table_id","data" jsonb_path_ops); \ No newline at end of file diff --git a/packages/db/migrations/meta/0231_snapshot.json b/packages/db/migrations/meta/0231_snapshot.json new file mode 100644 index 00000000000..b377763ca18 --- /dev/null +++ b/packages/db/migrations/meta/0231_snapshot.json @@ -0,0 +1,16759 @@ +{ + "id": "1f3a35a9-74f3-453d-b264-a187aa0c8535", + "prevId": "b6675160-a7cc-4e1b-bbc3-4b050284b789", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.a2a_agent": { + "name": "a2a_agent", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'1.0.0'" + }, + "capabilities": { + "name": "capabilities", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "skills": { + "name": "skills", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "authentication": { + "name": "authentication", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "signatures": { + "name": "signatures", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "is_published": { + "name": "is_published", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "a2a_agent_workflow_id_idx": { + "name": "a2a_agent_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "a2a_agent_created_by_idx": { + "name": "a2a_agent_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "a2a_agent_workspace_workflow_unique": { + "name": "a2a_agent_workspace_workflow_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"a2a_agent\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "a2a_agent_archived_at_idx": { + "name": "a2a_agent_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "a2a_agent_workspace_archived_partial_idx": { + "name": "a2a_agent_workspace_archived_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"a2a_agent\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "a2a_agent_workspace_id_workspace_id_fk": { + "name": "a2a_agent_workspace_id_workspace_id_fk", + "tableFrom": "a2a_agent", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "a2a_agent_workflow_id_workflow_id_fk": { + "name": "a2a_agent_workflow_id_workflow_id_fk", + "tableFrom": "a2a_agent", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "a2a_agent_created_by_user_id_fk": { + "name": "a2a_agent_created_by_user_id_fk", + "tableFrom": "a2a_agent", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.a2a_push_notification_config": { + "name": "a2a_push_notification_config", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "auth_schemes": { + "name": "auth_schemes", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "auth_credentials": { + "name": "auth_credentials", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "a2a_push_notification_config_task_unique": { + "name": "a2a_push_notification_config_task_unique", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "a2a_push_notification_config_task_id_a2a_task_id_fk": { + "name": "a2a_push_notification_config_task_id_a2a_task_id_fk", + "tableFrom": "a2a_push_notification_config", + "tableTo": "a2a_task", + "columnsFrom": ["task_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.a2a_task": { + "name": "a2a_task", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "a2a_task_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'submitted'" + }, + "messages": { + "name": "messages", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "artifacts": { + "name": "artifacts", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "a2a_task_agent_id_idx": { + "name": "a2a_task_agent_id_idx", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "a2a_task_session_id_idx": { + "name": "a2a_task_session_id_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "a2a_task_status_idx": { + "name": "a2a_task_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "a2a_task_execution_id_idx": { + "name": "a2a_task_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "a2a_task_created_at_idx": { + "name": "a2a_task_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "a2a_task_agent_id_a2a_agent_id_fk": { + "name": "a2a_task_agent_id_a2a_agent_id_fk", + "tableFrom": "a2a_task", + "tableTo": "a2a_agent", + "columnsFrom": ["agent_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.academy_certificate": { + "name": "academy_certificate", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "course_id": { + "name": "course_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "academy_cert_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "issued_at": { + "name": "issued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "certificate_number": { + "name": "certificate_number", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "academy_certificate_user_id_idx": { + "name": "academy_certificate_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_course_id_idx": { + "name": "academy_certificate_course_id_idx", + "columns": [ + { + "expression": "course_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_user_course_unique": { + "name": "academy_certificate_user_course_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "course_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_number_idx": { + "name": "academy_certificate_number_idx", + "columns": [ + { + "expression": "certificate_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_status_idx": { + "name": "academy_certificate_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "academy_certificate_user_id_user_id_fk": { + "name": "academy_certificate_user_id_user_id_fk", + "tableFrom": "academy_certificate", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "academy_certificate_certificate_number_unique": { + "name": "academy_certificate_certificate_number_unique", + "nullsNotDistinct": false, + "columns": ["certificate_number"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_user_id_idx": { + "name": "account_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_account_on_account_id_provider_id": { + "name": "idx_account_on_account_id_provider_id", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_key": { + "name": "api_key", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'personal'" + }, + "last_used": { + "name": "last_used", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "api_key_workspace_type_idx": { + "name": "api_key_workspace_type_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_key_user_type_idx": { + "name": "api_key_user_type_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_key_key_hash_idx": { + "name": "api_key_key_hash_idx", + "columns": [ + { + "expression": "key_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "api_key_user_id_user_id_fk": { + "name": "api_key_user_id_user_id_fk", + "tableFrom": "api_key", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "api_key_workspace_id_workspace_id_fk": { + "name": "api_key_workspace_id_workspace_id_fk", + "tableFrom": "api_key", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "api_key_created_by_user_id_fk": { + "name": "api_key_created_by_user_id_fk", + "tableFrom": "api_key", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "api_key_key_unique": { + "name": "api_key_key_unique", + "nullsNotDistinct": false, + "columns": ["key"] + } + }, + "policies": {}, + "checkConstraints": { + "workspace_type_check": { + "name": "workspace_type_check", + "value": "(type = 'workspace' AND workspace_id IS NOT NULL) OR (type = 'personal' AND workspace_id IS NULL)" + } + }, + "isRLSEnabled": false + }, + "public.async_jobs": { + "name": "async_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "run_at": { + "name": "run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "max_attempts": { + "name": "max_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 3 + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "output": { + "name": "output", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "async_jobs_status_started_at_idx": { + "name": "async_jobs_status_started_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_status_completed_at_idx": { + "name": "async_jobs_status_completed_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "completed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_schedule_pending_run_at_idx": { + "name": "async_jobs_schedule_pending_run_at_idx", + "columns": [ + { + "expression": "run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"async_jobs\".\"type\" = 'schedule-execution' AND \"async_jobs\".\"status\" = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_schedule_processing_started_at_idx": { + "name": "async_jobs_schedule_processing_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"async_jobs\".\"type\" = 'schedule-execution' AND \"async_jobs\".\"status\" = 'processing'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_log": { + "name": "audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_name": { + "name": "actor_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_email": { + "name": "actor_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resource_name": { + "name": "resource_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_log_workspace_created_idx": { + "name": "audit_log_workspace_created_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_workspace_created_at_id_idx": { + "name": "audit_log_workspace_created_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"created_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_actor_created_idx": { + "name": "audit_log_actor_created_idx", + "columns": [ + { + "expression": "actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_resource_idx": { + "name": "audit_log_resource_idx", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_action_idx": { + "name": "audit_log_action_idx", + "columns": [ + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_log_workspace_id_workspace_id_fk": { + "name": "audit_log_workspace_id_workspace_id_fk", + "tableFrom": "audit_log", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "audit_log_actor_id_user_id_fk": { + "name": "audit_log_actor_id_user_id_fk", + "tableFrom": "audit_log", + "tableTo": "user", + "columnsFrom": ["actor_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat": { + "name": "chat", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "customizations": { + "name": "customizations", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowed_emails": { + "name": "allowed_emails", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "output_configs": { + "name": "output_configs", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "identifier_idx": { + "name": "identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"chat\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_archived_at_partial_idx": { + "name": "chat_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"chat\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_chat_on_workflow_id_archived_at": { + "name": "idx_chat_on_workflow_id_archived_at", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_workflow_id_workflow_id_fk": { + "name": "chat_workflow_id_workflow_id_fk", + "tableFrom": "chat", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chat_user_id_user_id_fk": { + "name": "chat_user_id_user_id_fk", + "tableFrom": "chat", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_async_tool_calls": { + "name": "copilot_async_tool_calls", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "checkpoint_id": { + "name": "checkpoint_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "tool_call_id": { + "name": "tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "args": { + "name": "args", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "status": { + "name": "status", + "type": "copilot_async_tool_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "claimed_by": { + "name": "claimed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_async_tool_calls_run_id_idx": { + "name": "copilot_async_tool_calls_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_checkpoint_id_idx": { + "name": "copilot_async_tool_calls_checkpoint_id_idx", + "columns": [ + { + "expression": "checkpoint_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_tool_call_id_idx": { + "name": "copilot_async_tool_calls_tool_call_id_idx", + "columns": [ + { + "expression": "tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_status_idx": { + "name": "copilot_async_tool_calls_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_run_status_idx": { + "name": "copilot_async_tool_calls_run_status_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_tool_call_id_unique": { + "name": "copilot_async_tool_calls_tool_call_id_unique", + "columns": [ + { + "expression": "tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_async_tool_calls_run_id_copilot_runs_id_fk": { + "name": "copilot_async_tool_calls_run_id_copilot_runs_id_fk", + "tableFrom": "copilot_async_tool_calls", + "tableTo": "copilot_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_async_tool_calls_checkpoint_id_copilot_run_checkpoints_id_fk": { + "name": "copilot_async_tool_calls_checkpoint_id_copilot_run_checkpoints_id_fk", + "tableFrom": "copilot_async_tool_calls", + "tableTo": "copilot_run_checkpoints", + "columnsFrom": ["checkpoint_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_chats": { + "name": "copilot_chats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "chat_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'copilot'" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'claude-3-7-sonnet-latest'" + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "preview_yaml": { + "name": "preview_yaml", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "plan_artifact": { + "name": "plan_artifact", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "resources": { + "name": "resources", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "pinned": { + "name": "pinned", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_chats_user_id_idx": { + "name": "copilot_chats_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_workflow_id_idx": { + "name": "copilot_chats_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_user_workflow_idx": { + "name": "copilot_chats_user_workflow_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_user_workspace_idx": { + "name": "copilot_chats_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_created_at_idx": { + "name": "copilot_chats_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_updated_at_idx": { + "name": "copilot_chats_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_workspace_created_at_id_idx": { + "name": "copilot_chats_workspace_created_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"created_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_chats_user_id_user_id_fk": { + "name": "copilot_chats_user_id_user_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_chats_workflow_id_workflow_id_fk": { + "name": "copilot_chats_workflow_id_workflow_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_chats_workspace_id_workspace_id_fk": { + "name": "copilot_chats_workspace_id_workspace_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_feedback": { + "name": "copilot_feedback", + "schema": "", + "columns": { + "feedback_id": { + "name": "feedback_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_query": { + "name": "user_query", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_response": { + "name": "agent_response", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_positive": { + "name": "is_positive", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "feedback": { + "name": "feedback", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_yaml": { + "name": "workflow_yaml", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_feedback_user_id_idx": { + "name": "copilot_feedback_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_chat_id_idx": { + "name": "copilot_feedback_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_user_chat_idx": { + "name": "copilot_feedback_user_chat_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_is_positive_idx": { + "name": "copilot_feedback_is_positive_idx", + "columns": [ + { + "expression": "is_positive", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_created_at_idx": { + "name": "copilot_feedback_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_feedback_user_id_user_id_fk": { + "name": "copilot_feedback_user_id_user_id_fk", + "tableFrom": "copilot_feedback", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_feedback_chat_id_copilot_chats_id_fk": { + "name": "copilot_feedback_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_feedback", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_messages": { + "name": "copilot_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "stream_id": { + "name": "stream_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parent_message_id": { + "name": "parent_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tokens_in": { + "name": "tokens_in", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "tokens_out": { + "name": "tokens_out", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_messages_chat_message_unique": { + "name": "copilot_messages_chat_message_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_chat_created_at_idx": { + "name": "copilot_messages_chat_created_at_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_chat_seq_idx": { + "name": "copilot_messages_chat_seq_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "seq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_chat_stream_idx": { + "name": "copilot_messages_chat_stream_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "stream_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"stream_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_messages_chat_id_copilot_chats_id_fk": { + "name": "copilot_messages_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_messages", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_run_checkpoints": { + "name": "copilot_run_checkpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "pending_tool_call_id": { + "name": "pending_tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conversation_snapshot": { + "name": "conversation_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "agent_state": { + "name": "agent_state", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "provider_request": { + "name": "provider_request", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_run_checkpoints_run_id_idx": { + "name": "copilot_run_checkpoints_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_run_checkpoints_pending_tool_call_id_idx": { + "name": "copilot_run_checkpoints_pending_tool_call_id_idx", + "columns": [ + { + "expression": "pending_tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_run_checkpoints_run_pending_tool_unique": { + "name": "copilot_run_checkpoints_run_pending_tool_unique", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pending_tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_run_checkpoints_run_id_copilot_runs_id_fk": { + "name": "copilot_run_checkpoints_run_id_copilot_runs_id_fk", + "tableFrom": "copilot_run_checkpoints", + "tableTo": "copilot_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_runs": { + "name": "copilot_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_run_id": { + "name": "parent_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stream_id": { + "name": "stream_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent": { + "name": "agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "copilot_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "request_context": { + "name": "request_context", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "copilot_runs_execution_id_idx": { + "name": "copilot_runs_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_parent_run_id_idx": { + "name": "copilot_runs_parent_run_id_idx", + "columns": [ + { + "expression": "parent_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_chat_id_idx": { + "name": "copilot_runs_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_user_id_idx": { + "name": "copilot_runs_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_workflow_id_idx": { + "name": "copilot_runs_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_workspace_id_idx": { + "name": "copilot_runs_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_status_idx": { + "name": "copilot_runs_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_chat_execution_idx": { + "name": "copilot_runs_chat_execution_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_execution_started_at_idx": { + "name": "copilot_runs_execution_started_at_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_workspace_completed_at_id_idx": { + "name": "copilot_runs_workspace_completed_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"completed_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_stream_id_unique": { + "name": "copilot_runs_stream_id_unique", + "columns": [ + { + "expression": "stream_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_runs_chat_id_copilot_chats_id_fk": { + "name": "copilot_runs_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_user_id_user_id_fk": { + "name": "copilot_runs_user_id_user_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_workflow_id_workflow_id_fk": { + "name": "copilot_runs_workflow_id_workflow_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_workspace_id_workspace_id_fk": { + "name": "copilot_runs_workspace_id_workspace_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_workflow_read_hashes": { + "name": "copilot_workflow_read_hashes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_workflow_read_hashes_chat_id_idx": { + "name": "copilot_workflow_read_hashes_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_workflow_read_hashes_workflow_id_idx": { + "name": "copilot_workflow_read_hashes_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_workflow_read_hashes_chat_workflow_unique": { + "name": "copilot_workflow_read_hashes_chat_workflow_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_workflow_read_hashes_chat_id_copilot_chats_id_fk": { + "name": "copilot_workflow_read_hashes_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_workflow_read_hashes", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_workflow_read_hashes_workflow_id_workflow_id_fk": { + "name": "copilot_workflow_read_hashes_workflow_id_workflow_id_fk", + "tableFrom": "copilot_workflow_read_hashes", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credential": { + "name": "credential", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "credential_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env_key": { + "name": "env_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env_owner_user_id": { + "name": "env_owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_service_account_key": { + "name": "encrypted_service_account_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_workspace_id_idx": { + "name": "credential_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_type_idx": { + "name": "credential_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_provider_id_idx": { + "name": "credential_provider_id_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_account_id_idx": { + "name": "credential_account_id_idx", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_env_owner_user_id_idx": { + "name": "credential_env_owner_user_id_idx", + "columns": [ + { + "expression": "env_owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_account_unique": { + "name": "credential_workspace_account_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "account_id IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_env_unique": { + "name": "credential_workspace_env_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "type = 'env_workspace'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_personal_env_unique": { + "name": "credential_workspace_personal_env_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "type = 'env_personal'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_workspace_id_workspace_id_fk": { + "name": "credential_workspace_id_workspace_id_fk", + "tableFrom": "credential", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_account_id_account_id_fk": { + "name": "credential_account_id_account_id_fk", + "tableFrom": "credential", + "tableTo": "account", + "columnsFrom": ["account_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_env_owner_user_id_user_id_fk": { + "name": "credential_env_owner_user_id_user_id_fk", + "tableFrom": "credential", + "tableTo": "user", + "columnsFrom": ["env_owner_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_created_by_user_id_fk": { + "name": "credential_created_by_user_id_fk", + "tableFrom": "credential", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "credential_oauth_source_check": { + "name": "credential_oauth_source_check", + "value": "(type <> 'oauth') OR (account_id IS NOT NULL AND provider_id IS NOT NULL)" + }, + "credential_workspace_env_source_check": { + "name": "credential_workspace_env_source_check", + "value": "(type <> 'env_workspace') OR (env_key IS NOT NULL AND env_owner_user_id IS NULL)" + }, + "credential_personal_env_source_check": { + "name": "credential_personal_env_source_check", + "value": "(type <> 'env_personal') OR (env_key IS NOT NULL AND env_owner_user_id IS NOT NULL)" + } + }, + "isRLSEnabled": false + }, + "public.credential_member": { + "name": "credential_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "credential_member_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "status": { + "name": "status", + "type": "credential_member_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_member_user_id_idx": { + "name": "credential_member_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_role_idx": { + "name": "credential_member_role_idx", + "columns": [ + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_status_idx": { + "name": "credential_member_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_unique": { + "name": "credential_member_unique", + "columns": [ + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_member_credential_id_credential_id_fk": { + "name": "credential_member_credential_id_credential_id_fk", + "tableFrom": "credential_member", + "tableTo": "credential", + "columnsFrom": ["credential_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_member_user_id_user_id_fk": { + "name": "credential_member_user_id_user_id_fk", + "tableFrom": "credential_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_member_invited_by_user_id_fk": { + "name": "credential_member_invited_by_user_id_fk", + "tableFrom": "credential_member", + "tableTo": "user", + "columnsFrom": ["invited_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credential_set": { + "name": "credential_set", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_set_created_by_idx": { + "name": "credential_set_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_set_org_name_unique": { + "name": "credential_set_org_name_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_set_provider_id_idx": { + "name": "credential_set_provider_id_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_set_organization_id_organization_id_fk": { + "name": "credential_set_organization_id_organization_id_fk", + "tableFrom": "credential_set", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_set_created_by_user_id_fk": { + "name": "credential_set_created_by_user_id_fk", + "tableFrom": "credential_set", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credential_set_invitation": { + "name": "credential_set_invitation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "credential_set_id": { + "name": "credential_set_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "credential_set_invitation_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "accepted_at": { + "name": "accepted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "accepted_by_user_id": { + "name": "accepted_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_set_invitation_set_id_idx": { + "name": "credential_set_invitation_set_id_idx", + "columns": [ + { + "expression": "credential_set_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_set_invitation_token_idx": { + "name": "credential_set_invitation_token_idx", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_set_invitation_status_idx": { + "name": "credential_set_invitation_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_set_invitation_expires_at_idx": { + "name": "credential_set_invitation_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_set_invitation_credential_set_id_credential_set_id_fk": { + "name": "credential_set_invitation_credential_set_id_credential_set_id_fk", + "tableFrom": "credential_set_invitation", + "tableTo": "credential_set", + "columnsFrom": ["credential_set_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_set_invitation_invited_by_user_id_fk": { + "name": "credential_set_invitation_invited_by_user_id_fk", + "tableFrom": "credential_set_invitation", + "tableTo": "user", + "columnsFrom": ["invited_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_set_invitation_accepted_by_user_id_user_id_fk": { + "name": "credential_set_invitation_accepted_by_user_id_user_id_fk", + "tableFrom": "credential_set_invitation", + "tableTo": "user", + "columnsFrom": ["accepted_by_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "credential_set_invitation_token_unique": { + "name": "credential_set_invitation_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credential_set_member": { + "name": "credential_set_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "credential_set_id": { + "name": "credential_set_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "credential_set_member_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_set_member_user_id_idx": { + "name": "credential_set_member_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_set_member_unique": { + "name": "credential_set_member_unique", + "columns": [ + { + "expression": "credential_set_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_set_member_status_idx": { + "name": "credential_set_member_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_set_member_credential_set_id_credential_set_id_fk": { + "name": "credential_set_member_credential_set_id_credential_set_id_fk", + "tableFrom": "credential_set_member", + "tableTo": "credential_set", + "columnsFrom": ["credential_set_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_set_member_user_id_user_id_fk": { + "name": "credential_set_member_user_id_user_id_fk", + "tableFrom": "credential_set_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_set_member_invited_by_user_id_fk": { + "name": "credential_set_member_invited_by_user_id_fk", + "tableFrom": "credential_set_member", + "tableTo": "user", + "columnsFrom": ["invited_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.custom_tools": { + "name": "custom_tools", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schema": { + "name": "schema", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "custom_tools_workspace_id_idx": { + "name": "custom_tools_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_tools_workspace_title_unique": { + "name": "custom_tools_workspace_title_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "title", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "custom_tools_workspace_id_workspace_id_fk": { + "name": "custom_tools_workspace_id_workspace_id_fk", + "tableFrom": "custom_tools", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "custom_tools_user_id_user_id_fk": { + "name": "custom_tools_user_id_user_id_fk", + "tableFrom": "custom_tools", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.data_drain_runs": { + "name": "data_drain_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "drain_id": { + "name": "drain_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "data_drain_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "trigger": { + "name": "trigger", + "type": "data_drain_run_trigger", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "rows_exported": { + "name": "rows_exported", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "bytes_written": { + "name": "bytes_written", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cursor_before": { + "name": "cursor_before", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cursor_after": { + "name": "cursor_after", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "locators": { + "name": "locators", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + } + }, + "indexes": { + "data_drain_runs_drain_started_idx": { + "name": "data_drain_runs_drain_started_idx", + "columns": [ + { + "expression": "drain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "data_drain_runs_drain_id_data_drains_id_fk": { + "name": "data_drain_runs_drain_id_data_drains_id_fk", + "tableFrom": "data_drain_runs", + "tableTo": "data_drains", + "columnsFrom": ["drain_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.data_drains": { + "name": "data_drains", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "data_drain_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "destination_type": { + "name": "destination_type", + "type": "data_drain_destination", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "destination_config": { + "name": "destination_config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "destination_credentials": { + "name": "destination_credentials", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schedule_cadence": { + "name": "schedule_cadence", + "type": "data_drain_cadence", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "cursor": { + "name": "cursor", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_success_at": { + "name": "last_success_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "data_drains_org_idx": { + "name": "data_drains_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "data_drains_due_idx": { + "name": "data_drains_due_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "data_drains_org_name_unique": { + "name": "data_drains_org_name_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "data_drains_organization_id_organization_id_fk": { + "name": "data_drains_organization_id_organization_id_fk", + "tableFrom": "data_drains", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "data_drains_created_by_user_id_fk": { + "name": "data_drains_created_by_user_id_fk", + "tableFrom": "data_drains", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.docs_embeddings": { + "name": "docs_embeddings", + "schema": "", + "columns": { + "chunk_id": { + "name": "chunk_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chunk_text": { + "name": "chunk_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_document": { + "name": "source_document", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_link": { + "name": "source_link", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "header_text": { + "name": "header_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "header_level": { + "name": "header_level", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": true + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "chunk_text_tsv": { + "name": "chunk_text_tsv", + "type": "tsvector", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "to_tsvector('english', \"docs_embeddings\".\"chunk_text\")", + "type": "stored" + } + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "docs_emb_source_document_idx": { + "name": "docs_emb_source_document_idx", + "columns": [ + { + "expression": "source_document", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_header_level_idx": { + "name": "docs_emb_header_level_idx", + "columns": [ + { + "expression": "header_level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_source_header_idx": { + "name": "docs_emb_source_header_idx", + "columns": [ + { + "expression": "source_document", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "header_level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_model_idx": { + "name": "docs_emb_model_idx", + "columns": [ + { + "expression": "embedding_model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_created_at_idx": { + "name": "docs_emb_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_embedding_vector_hnsw_idx": { + "name": "docs_embedding_vector_hnsw_idx", + "columns": [ + { + "expression": "embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "docs_emb_metadata_gin_idx": { + "name": "docs_emb_metadata_gin_idx", + "columns": [ + { + "expression": "metadata", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "docs_emb_chunk_text_fts_idx": { + "name": "docs_emb_chunk_text_fts_idx", + "columns": [ + { + "expression": "chunk_text_tsv", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "docs_embedding_not_null_check": { + "name": "docs_embedding_not_null_check", + "value": "\"embedding\" IS NOT NULL" + }, + "docs_header_level_check": { + "name": "docs_header_level_check", + "value": "\"header_level\" >= 1 AND \"header_level\" <= 6" + } + }, + "isRLSEnabled": false + }, + "public.document": { + "name": "document", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_url": { + "name": "file_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "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.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 + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "embedding_dimension": { + "name": "embedding_dimension", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1536 + }, + "chunking_config": { + "name": "chunking_config", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{\"maxSize\": 1024, \"minSize\": 1, \"overlap\": 200}'" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "kb_user_id_idx": { + "name": "kb_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_workspace_id_idx": { + "name": "kb_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_user_workspace_idx": { + "name": "kb_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_deleted_at_idx": { + "name": "kb_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_workspace_deleted_partial_idx": { + "name": "kb_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_base\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_workspace_name_active_unique": { + "name": "kb_workspace_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"knowledge_base\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_base_user_id_user_id_fk": { + "name": "knowledge_base_user_id_user_id_fk", + "tableFrom": "knowledge_base", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "knowledge_base_workspace_id_workspace_id_fk": { + "name": "knowledge_base_workspace_id_workspace_id_fk", + "tableFrom": "knowledge_base", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_base_tag_definitions": { + "name": "knowledge_base_tag_definitions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tag_slot": { + "name": "tag_slot", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "field_type": { + "name": "field_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "kb_tag_definitions_kb_slot_idx": { + "name": "kb_tag_definitions_kb_slot_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tag_slot", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_tag_definitions_kb_display_name_idx": { + "name": "kb_tag_definitions_kb_display_name_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "display_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_tag_definitions_kb_id_idx": { + "name": "kb_tag_definitions_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_base_tag_definitions_knowledge_base_id_knowledge_base_id_fk": { + "name": "knowledge_base_tag_definitions_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "knowledge_base_tag_definitions", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_connector": { + "name": "knowledge_connector", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connector_type": { + "name": "connector_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_api_key": { + "name": "encrypted_api_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_config": { + "name": "source_config", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "sync_mode": { + "name": "sync_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'full'" + }, + "sync_interval_minutes": { + "name": "sync_interval_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1440 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "last_sync_at": { + "name": "last_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_sync_error": { + "name": "last_sync_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_sync_doc_count": { + "name": "last_sync_doc_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "next_sync_at": { + "name": "next_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "consecutive_failures": { + "name": "consecutive_failures", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "kc_knowledge_base_id_idx": { + "name": "kc_knowledge_base_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_status_next_sync_idx": { + "name": "kc_status_next_sync_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_sync_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_archived_at_partial_idx": { + "name": "kc_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_connector\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_deleted_at_partial_idx": { + "name": "kc_deleted_at_partial_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_connector\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_connector_knowledge_base_id_knowledge_base_id_fk": { + "name": "knowledge_connector_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "knowledge_connector", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_connector_sync_log": { + "name": "knowledge_connector_sync_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connector_id": { + "name": "connector_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "docs_added": { + "name": "docs_added", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_updated": { + "name": "docs_updated", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_deleted": { + "name": "docs_deleted", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_unchanged": { + "name": "docs_unchanged", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_failed": { + "name": "docs_failed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "kcsl_connector_id_idx": { + "name": "kcsl_connector_id_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_connector_sync_log_connector_id_knowledge_connector_id_fk": { + "name": "knowledge_connector_sync_log_connector_id_knowledge_connector_id_fk", + "tableFrom": "knowledge_connector_sync_log", + "tableTo": "knowledge_connector", + "columnsFrom": ["connector_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_server_oauth": { + "name": "mcp_server_oauth", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "mcp_server_id": { + "name": "mcp_server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_information": { + "name": "client_information", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tokens": { + "name": "tokens", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "code_verifier": { + "name": "code_verifier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state_created_at": { + "name": "state_created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_refreshed_at": { + "name": "last_refreshed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_server_oauth_server_unique": { + "name": "mcp_server_oauth_server_unique", + "columns": [ + { + "expression": "mcp_server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_server_oauth_state_idx": { + "name": "mcp_server_oauth_state_idx", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_server_oauth_mcp_server_id_mcp_servers_id_fk": { + "name": "mcp_server_oauth_mcp_server_id_mcp_servers_id_fk", + "tableFrom": "mcp_server_oauth", + "tableTo": "mcp_servers", + "columnsFrom": ["mcp_server_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_server_oauth_user_id_user_id_fk": { + "name": "mcp_server_oauth_user_id_user_id_fk", + "tableFrom": "mcp_server_oauth", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "mcp_server_oauth_workspace_id_workspace_id_fk": { + "name": "mcp_server_oauth_workspace_id_workspace_id_fk", + "tableFrom": "mcp_server_oauth", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_servers": { + "name": "mcp_servers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "transport": { + "name": "transport", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'headers'" + }, + "oauth_client_id": { + "name": "oauth_client_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oauth_client_secret": { + "name": "oauth_client_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "headers": { + "name": "headers", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "timeout": { + "name": "timeout", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 30000 + }, + "retries": { + "name": "retries", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 3 + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_connected": { + "name": "last_connected", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "connection_status": { + "name": "connection_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'disconnected'" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status_config": { + "name": "status_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "tool_count": { + "name": "tool_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_tools_refresh": { + "name": "last_tools_refresh", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_requests": { + "name": "total_requests", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_used": { + "name": "last_used", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_servers_workspace_enabled_idx": { + "name": "mcp_servers_workspace_enabled_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_servers_workspace_deleted_partial_idx": { + "name": "mcp_servers_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"mcp_servers\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_servers_workspace_id_workspace_id_fk": { + "name": "mcp_servers_workspace_id_workspace_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_servers_created_by_user_id_fk": { + "name": "mcp_servers_created_by_user_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.member": { + "name": "member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "member_user_id_unique": { + "name": "member_user_id_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "member_organization_id_idx": { + "name": "member_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "member_user_id_user_id_fk": { + "name": "member_user_id_user_id_fk", + "tableFrom": "member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "member_organization_id_organization_id_fk": { + "name": "member_organization_id_organization_id_fk", + "tableFrom": "member", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memory": { + "name": "memory", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "memory_key_idx": { + "name": "memory_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workspace_idx": { + "name": "memory_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workspace_key_idx": { + "name": "memory_workspace_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workspace_deleted_partial_idx": { + "name": "memory_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"memory\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "memory_workspace_id_workspace_id_fk": { + "name": "memory_workspace_id_workspace_id_fk", + "tableFrom": "memory", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_inbox_allowed_sender": { + "name": "mothership_inbox_allowed_sender", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "added_by": { + "name": "added_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "inbox_sender_ws_email_idx": { + "name": "inbox_sender_ws_email_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mothership_inbox_allowed_sender_workspace_id_workspace_id_fk": { + "name": "mothership_inbox_allowed_sender_workspace_id_workspace_id_fk", + "tableFrom": "mothership_inbox_allowed_sender", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mothership_inbox_allowed_sender_added_by_user_id_fk": { + "name": "mothership_inbox_allowed_sender_added_by_user_id_fk", + "tableFrom": "mothership_inbox_allowed_sender", + "tableTo": "user", + "columnsFrom": ["added_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_inbox_task": { + "name": "mothership_inbox_task", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "from_email": { + "name": "from_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "from_name": { + "name": "from_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body_preview": { + "name": "body_preview", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "body_text": { + "name": "body_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "body_html": { + "name": "body_html", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_message_id": { + "name": "email_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "in_reply_to": { + "name": "in_reply_to", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "response_message_id": { + "name": "response_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agentmail_message_id": { + "name": "agentmail_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'received'" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "trigger_job_id": { + "name": "trigger_job_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "result_summary": { + "name": "result_summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rejection_reason": { + "name": "rejection_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "has_attachments": { + "name": "has_attachments", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "cc_recipients": { + "name": "cc_recipients", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "processing_started_at": { + "name": "processing_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "inbox_task_ws_created_at_idx": { + "name": "inbox_task_ws_created_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inbox_task_ws_status_idx": { + "name": "inbox_task_ws_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inbox_task_response_msg_id_idx": { + "name": "inbox_task_response_msg_id_idx", + "columns": [ + { + "expression": "response_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inbox_task_email_msg_id_idx": { + "name": "inbox_task_email_msg_id_idx", + "columns": [ + { + "expression": "email_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mothership_inbox_task_workspace_id_workspace_id_fk": { + "name": "mothership_inbox_task_workspace_id_workspace_id_fk", + "tableFrom": "mothership_inbox_task", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mothership_inbox_task_chat_id_copilot_chats_id_fk": { + "name": "mothership_inbox_task_chat_id_copilot_chats_id_fk", + "tableFrom": "mothership_inbox_task", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_inbox_webhook": { + "name": "mothership_inbox_webhook", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "webhook_id": { + "name": "webhook_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "mothership_inbox_webhook_workspace_id_workspace_id_fk": { + "name": "mothership_inbox_webhook_workspace_id_workspace_id_fk", + "tableFrom": "mothership_inbox_webhook", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "mothership_inbox_webhook_workspace_id_unique": { + "name": "mothership_inbox_webhook_workspace_id_unique", + "nullsNotDistinct": false, + "columns": ["workspace_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_settings": { + "name": "mothership_settings", + "schema": "", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "mcp_tool_refs": { + "name": "mcp_tool_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "custom_tool_refs": { + "name": "custom_tool_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "skill_refs": { + "name": "skill_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mothership_settings_workspace_id_idx": { + "name": "mothership_settings_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mothership_settings_workspace_id_workspace_id_fk": { + "name": "mothership_settings_workspace_id_workspace_id_fk", + "tableFrom": "mothership_settings", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization": { + "name": "organization", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "whitelabel_settings": { + "name": "whitelabel_settings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "data_retention_settings": { + "name": "data_retention_settings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "org_usage_limit": { + "name": "org_usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "departed_member_usage": { + "name": "departed_member_usage", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "credit_balance": { + "name": "credit_balance", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.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": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.paused_executions": { + "name": "paused_executions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_snapshot": { + "name": "execution_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "pause_points": { + "name": "pause_points", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "total_pause_count": { + "name": "total_pause_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "resumed_count": { + "name": "resumed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'paused'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "paused_at": { + "name": "paused_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "next_resume_at": { + "name": "next_resume_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "paused_executions_workflow_id_idx": { + "name": "paused_executions_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paused_executions_status_idx": { + "name": "paused_executions_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paused_executions_execution_id_unique": { + "name": "paused_executions_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paused_executions_next_resume_at_idx": { + "name": "paused_executions_next_resume_at_idx", + "columns": [ + { + "expression": "next_resume_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "status = 'paused' AND next_resume_at IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "paused_executions_workflow_id_workflow_id_fk": { + "name": "paused_executions_workflow_id_workflow_id_fk", + "tableFrom": "paused_executions", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pending_credential_draft": { + "name": "pending_credential_draft", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pending_draft_user_provider_ws": { + "name": "pending_draft_user_provider_ws", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pending_credential_draft_user_id_user_id_fk": { + "name": "pending_credential_draft_user_id_user_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pending_credential_draft_workspace_id_workspace_id_fk": { + "name": "pending_credential_draft_workspace_id_workspace_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pending_credential_draft_credential_id_credential_id_fk": { + "name": "pending_credential_draft_credential_id_credential_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "credential", + "columnsFrom": ["credential_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission_group": { + "name": "permission_group", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "auto_add_new_members": { + "name": "auto_add_new_members", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "permission_group_created_by_idx": { + "name": "permission_group_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_workspace_name_unique": { + "name": "permission_group_workspace_name_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_workspace_auto_add_unique": { + "name": "permission_group_workspace_auto_add_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "auto_add_new_members = true", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_group_workspace_id_workspace_id_fk": { + "name": "permission_group_workspace_id_workspace_id_fk", + "tableFrom": "permission_group", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_created_by_user_id_fk": { + "name": "permission_group_created_by_user_id_fk", + "tableFrom": "permission_group", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission_group_member": { + "name": "permission_group_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "permission_group_id": { + "name": "permission_group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "assigned_by": { + "name": "assigned_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assigned_at": { + "name": "assigned_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permission_group_member_group_id_idx": { + "name": "permission_group_member_group_id_idx", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_member_group_user_unique": { + "name": "permission_group_member_group_user_unique", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_member_workspace_user_unique": { + "name": "permission_group_member_workspace_user_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_group_member_permission_group_id_permission_group_id_fk": { + "name": "permission_group_member_permission_group_id_permission_group_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "permission_group", + "columnsFrom": ["permission_group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_member_workspace_id_workspace_id_fk": { + "name": "permission_group_member_workspace_id_workspace_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_member_user_id_user_id_fk": { + "name": "permission_group_member_user_id_user_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_member_assigned_by_user_id_fk": { + "name": "permission_group_member_assigned_by_user_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "user", + "columnsFrom": ["assigned_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permissions": { + "name": "permissions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission_type": { + "name": "permission_type", + "type": "permission_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permissions_user_id_idx": { + "name": "permissions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_entity_idx": { + "name": "permissions_entity_idx", + "columns": [ + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_type_idx": { + "name": "permissions_user_entity_type_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_permission_idx": { + "name": "permissions_user_entity_permission_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "permission_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_idx": { + "name": "permissions_user_entity_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_unique_constraint": { + "name": "permissions_unique_constraint", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permissions_user_id_user_id_fk": { + "name": "permissions_user_id_user_id_fk", + "tableFrom": "permissions", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rate_limit_bucket": { + "name": "rate_limit_bucket", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tokens": { + "name": "tokens", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "last_refill_at": { + "name": "last_refill_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.resume_queue": { + "name": "resume_queue", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "paused_execution_id": { + "name": "paused_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_execution_id": { + "name": "parent_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "new_execution_id": { + "name": "new_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "context_id": { + "name": "context_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resume_input": { + "name": "resume_input", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "queued_at": { + "name": "queued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "failure_reason": { + "name": "failure_reason", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "resume_queue_parent_status_idx": { + "name": "resume_queue_parent_status_idx", + "columns": [ + { + "expression": "parent_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "resume_queue_new_execution_idx": { + "name": "resume_queue_new_execution_idx", + "columns": [ + { + "expression": "new_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "resume_queue_paused_execution_id_paused_executions_id_fk": { + "name": "resume_queue_paused_execution_id_paused_executions_id_fk", + "tableFrom": "resume_queue", + "tableTo": "paused_executions", + "columnsFrom": ["paused_execution_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "impersonated_by": { + "name": "impersonated_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "session_user_id_idx": { + "name": "session_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "session_token_idx": { + "name": "session_token_idx", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_active_organization_id_organization_id_fk": { + "name": "session_active_organization_id_organization_id_fk", + "tableFrom": "session", + "tableTo": "organization", + "columnsFrom": ["active_organization_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.settings": { + "name": "settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "theme": { + "name": "theme", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'system'" + }, + "auto_connect": { + "name": "auto_connect", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "telemetry_enabled": { + "name": "telemetry_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "email_preferences": { + "name": "email_preferences", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "billing_usage_notifications_enabled": { + "name": "billing_usage_notifications_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "show_training_controls": { + "name": "show_training_controls", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "super_user_mode_enabled": { + "name": "super_user_mode_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "mothership_environment": { + "name": "mothership_environment", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'default'" + }, + "error_notifications_enabled": { + "name": "error_notifications_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "snap_to_grid_size": { + "name": "snap_to_grid_size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "show_action_bar": { + "name": "show_action_bar", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "copilot_enabled_models": { + "name": "copilot_enabled_models", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "copilot_auto_allowed_tools": { + "name": "copilot_auto_allowed_tools", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "last_active_workspace_id": { + "name": "last_active_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "settings_user_id_user_id_fk": { + "name": "settings_user_id_user_id_fk", + "tableFrom": "settings", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "settings_user_id_unique": { + "name": "settings_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skill": { + "name": "skill", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "skill_workspace_name_unique": { + "name": "skill_workspace_name_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skill_workspace_id_workspace_id_fk": { + "name": "skill_workspace_id_workspace_id_fk", + "tableFrom": "skill", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "skill_user_id_user_id_fk": { + "name": "skill_user_id_user_id_fk", + "tableFrom": "skill", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sso_provider": { + "name": "sso_provider", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "oidc_config": { + "name": "oidc_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "saml_config": { + "name": "saml_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "sso_provider_provider_id_idx": { + "name": "sso_provider_provider_id_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_domain_idx": { + "name": "sso_provider_domain_idx", + "columns": [ + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_user_id_idx": { + "name": "sso_provider_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_organization_id_idx": { + "name": "sso_provider_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sso_provider_user_id_user_id_fk": { + "name": "sso_provider_user_id_user_id_fk", + "tableFrom": "sso_provider", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sso_provider_organization_id_organization_id_fk": { + "name": "sso_provider_organization_id_organization_id_fk", + "tableFrom": "sso_provider", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.subscription": { + "name": "subscription", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "period_start": { + "name": "period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "period_end": { + "name": "period_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancel_at_period_end": { + "name": "cancel_at_period_end", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "cancel_at": { + "name": "cancel_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "canceled_at": { + "name": "canceled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "seats": { + "name": "seats", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "trial_start": { + "name": "trial_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trial_end": { + "name": "trial_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "billing_interval": { + "name": "billing_interval", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_schedule_id": { + "name": "stripe_schedule_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "subscription_reference_status_idx": { + "name": "subscription_reference_status_idx", + "columns": [ + { + "expression": "reference_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "check_enterprise_metadata": { + "name": "check_enterprise_metadata", + "value": "plan != 'enterprise' OR metadata IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.table_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 + }, + "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.usage_log": { + "name": "usage_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "usage_log_category", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "usage_log_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "cost": { + "name": "cost", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "event_key": { + "name": "event_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "billing_entity_type": { + "name": "billing_entity_type", + "type": "billing_entity_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "billing_entity_id": { + "name": "billing_entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "billing_period_start": { + "name": "billing_period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "billing_period_end": { + "name": "billing_period_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "usage_log_user_created_at_idx": { + "name": "usage_log_user_created_at_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_source_idx": { + "name": "usage_log_source_idx", + "columns": [ + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_workspace_id_idx": { + "name": "usage_log_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_workflow_id_idx": { + "name": "usage_log_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_event_key_unique": { + "name": "usage_log_event_key_unique", + "columns": [ + { + "expression": "event_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"usage_log\".\"event_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_billing_entity_period_idx": { + "name": "usage_log_billing_entity_period_idx", + "columns": [ + { + "expression": "billing_entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_period_start", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_period_end", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_log\".\"billing_entity_type\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_workspace_created_at_idx": { + "name": "usage_log_workspace_created_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_execution_id_idx": { + "name": "usage_log_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "usage_log_user_id_user_id_fk": { + "name": "usage_log_user_id_user_id_fk", + "tableFrom": "usage_log", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "usage_log_workspace_id_workspace_id_fk": { + "name": "usage_log_workspace_id_workspace_id_fk", + "tableFrom": "usage_log", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "usage_log_workflow_id_workflow_id_fk": { + "name": "usage_log_workflow_id_workflow_id_fk", + "tableFrom": "usage_log", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "usage_log_billing_scope_all_or_none": { + "name": "usage_log_billing_scope_all_or_none", + "value": "(\n (\"usage_log\".\"billing_entity_type\" IS NULL AND \"usage_log\".\"billing_entity_id\" IS NULL AND \"usage_log\".\"billing_period_start\" IS NULL AND \"usage_log\".\"billing_period_end\" IS NULL)\n OR\n (\"usage_log\".\"billing_entity_type\" IS NOT NULL AND \"usage_log\".\"billing_entity_id\" IS NOT NULL AND \"usage_log\".\"billing_period_start\" IS NOT NULL AND \"usage_log\".\"billing_period_end\" IS NOT NULL AND \"usage_log\".\"billing_period_start\" < \"usage_log\".\"billing_period_end\")\n )" + } + }, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "normalized_email": { + "name": "normalized_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'user'" + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + }, + "user_normalized_email_unique": { + "name": "user_normalized_email_unique", + "nullsNotDistinct": false, + "columns": ["normalized_email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_stats": { + "name": "user_stats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "total_manual_executions": { + "name": "total_manual_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_api_calls": { + "name": "total_api_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_webhook_triggers": { + "name": "total_webhook_triggers", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_scheduled_executions": { + "name": "total_scheduled_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_chat_executions": { + "name": "total_chat_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_mcp_executions": { + "name": "total_mcp_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_a2a_executions": { + "name": "total_a2a_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_tokens_used": { + "name": "total_tokens_used", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_cost": { + "name": "total_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_usage_limit": { + "name": "current_usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'5'" + }, + "usage_limit_updated_at": { + "name": "usage_limit_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "current_period_cost": { + "name": "current_period_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "last_period_cost": { + "name": "last_period_cost", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "billed_overage_this_period": { + "name": "billed_overage_this_period", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "pro_period_cost_snapshot": { + "name": "pro_period_cost_snapshot", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "pro_period_cost_snapshot_at": { + "name": "pro_period_cost_snapshot_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "credit_balance": { + "name": "credit_balance", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "total_copilot_cost": { + "name": "total_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_period_copilot_cost": { + "name": "current_period_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "last_period_copilot_cost": { + "name": "last_period_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "total_copilot_tokens": { + "name": "total_copilot_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_copilot_calls": { + "name": "total_copilot_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_mcp_copilot_calls": { + "name": "total_mcp_copilot_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_mcp_copilot_cost": { + "name": "total_mcp_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_period_mcp_copilot_cost": { + "name": "current_period_mcp_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_active": { + "name": "last_active", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "billing_blocked": { + "name": "billing_blocked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "billing_blocked_reason": { + "name": "billing_blocked_reason", + "type": "billing_blocked_reason", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "user_stats_user_id_user_id_fk": { + "name": "user_stats_user_id_user_id_fk", + "tableFrom": "user_stats", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_stats_user_id_unique": { + "name": "user_stats_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_table_definitions": { + "name": "user_table_definitions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "schema": { + "name": "schema", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "max_rows": { + "name": "max_rows", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10000 + }, + "row_count": { + "name": "row_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_table_def_workspace_id_idx": { + "name": "user_table_def_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_workspace_name_unique": { + "name": "user_table_def_workspace_name_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"user_table_definitions\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_archived_at_idx": { + "name": "user_table_def_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_workspace_archived_partial_idx": { + "name": "user_table_def_workspace_archived_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"user_table_definitions\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_table_definitions_workspace_id_workspace_id_fk": { + "name": "user_table_definitions_workspace_id_workspace_id_fk", + "tableFrom": "user_table_definitions", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_table_definitions_created_by_user_id_fk": { + "name": "user_table_definitions_created_by_user_id_fk", + "tableFrom": "user_table_definitions", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_table_rows": { + "name": "user_table_rows", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "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_table_id_idx": { + "name": "user_table_rows_table_id_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_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 + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_config": { + "name": "provider_config", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "failed_count": { + "name": "failed_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_failed_at": { + "name": "last_failed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "credential_set_id": { + "name": "credential_set_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "path_deployment_unique": { + "name": "path_deployment_unique", + "columns": [ + { + "expression": "path", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"webhook\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_workflow_deployment_idx": { + "name": "webhook_workflow_deployment_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_credential_set_id_idx": { + "name": "webhook_credential_set_id_idx", + "columns": [ + { + "expression": "credential_set_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_archived_at_partial_idx": { + "name": "webhook_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"webhook\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_webhook_on_provider_is_active_workflow_id_deploym_bdeed5468": { + "name": "idx_webhook_on_provider_is_active_workflow_id_deploym_bdeed5468", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_webhook_on_workflow_id_block_id_updated_at_desc": { + "name": "idx_webhook_on_workflow_id_block_id_updated_at_desc", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "webhook_workflow_id_workflow_id_fk": { + "name": "webhook_workflow_id_workflow_id_fk", + "tableFrom": "webhook", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "webhook_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "webhook_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "webhook", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "webhook_credential_set_id_credential_set_id_fk": { + "name": "webhook_credential_set_id_credential_set_id_fk", + "tableFrom": "webhook", + "tableTo": "credential_set", + "columnsFrom": ["credential_set_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow": { + "name": "workflow", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_synced": { + "name": "last_synced", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "is_deployed": { + "name": "is_deployed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deployed_at": { + "name": "deployed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "is_public_api": { + "name": "is_public_api", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "run_count": { + "name": "run_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workflow_user_id_idx": { + "name": "workflow_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_id_idx": { + "name": "workflow_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_user_workspace_idx": { + "name": "workflow_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_folder_name_active_unique": { + "name": "workflow_workspace_folder_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"folder_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_sort_idx": { + "name": "workflow_folder_sort_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_archived_at_idx": { + "name": "workflow_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_archived_partial_idx": { + "name": "workflow_workspace_archived_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_user_id_user_id_fk": { + "name": "workflow_user_id_user_id_fk", + "tableFrom": "workflow", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_workspace_id_workspace_id_fk": { + "name": "workflow_workspace_id_workspace_id_fk", + "tableFrom": "workflow", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_folder_id_workflow_folder_id_fk": { + "name": "workflow_folder_id_workflow_folder_id_fk", + "tableFrom": "workflow", + "tableTo": "workflow_folder", + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_blocks": { + "name": "workflow_blocks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position_x": { + "name": "position_x", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "position_y": { + "name": "position_y", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "horizontal_handles": { + "name": "horizontal_handles", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "is_wide": { + "name": "is_wide", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "advanced_mode": { + "name": "advanced_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "trigger_mode": { + "name": "trigger_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "height": { + "name": "height", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "sub_blocks": { + "name": "sub_blocks", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "outputs": { + "name": "outputs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_blocks_workflow_id_idx": { + "name": "workflow_blocks_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_blocks_type_idx": { + "name": "workflow_blocks_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_blocks_workflow_id_workflow_id_fk": { + "name": "workflow_blocks_workflow_id_workflow_id_fk", + "tableFrom": "workflow_blocks", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_checkpoints": { + "name": "workflow_checkpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_state": { + "name": "workflow_state", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_checkpoints_user_id_idx": { + "name": "workflow_checkpoints_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_workflow_id_idx": { + "name": "workflow_checkpoints_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_chat_id_idx": { + "name": "workflow_checkpoints_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_message_id_idx": { + "name": "workflow_checkpoints_message_id_idx", + "columns": [ + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_user_workflow_idx": { + "name": "workflow_checkpoints_user_workflow_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_workflow_chat_idx": { + "name": "workflow_checkpoints_workflow_chat_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_created_at_idx": { + "name": "workflow_checkpoints_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_chat_created_at_idx": { + "name": "workflow_checkpoints_chat_created_at_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_checkpoints_user_id_user_id_fk": { + "name": "workflow_checkpoints_user_id_user_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_checkpoints_workflow_id_workflow_id_fk": { + "name": "workflow_checkpoints_workflow_id_workflow_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_checkpoints_chat_id_copilot_chats_id_fk": { + "name": "workflow_checkpoints_chat_id_copilot_chats_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_deployment_version": { + "name": "workflow_deployment_version", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workflow_deployment_version_workflow_version_unique": { + "name": "workflow_deployment_version_workflow_version_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_version_workflow_active_idx": { + "name": "workflow_deployment_version_workflow_active_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_version_created_at_idx": { + "name": "workflow_deployment_version_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_deployment_version_workflow_id_workflow_id_fk": { + "name": "workflow_deployment_version_workflow_id_workflow_id_fk", + "tableFrom": "workflow_deployment_version", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_edges": { + "name": "workflow_edges", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_block_id": { + "name": "source_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_block_id": { + "name": "target_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_handle": { + "name": "source_handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_handle": { + "name": "target_handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_edges_workflow_id_idx": { + "name": "workflow_edges_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_edges_workflow_source_idx": { + "name": "workflow_edges_workflow_source_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_edges_workflow_target_idx": { + "name": "workflow_edges_workflow_target_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_edges_workflow_id_workflow_id_fk": { + "name": "workflow_edges_workflow_id_workflow_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_edges_source_block_id_workflow_blocks_id_fk": { + "name": "workflow_edges_source_block_id_workflow_blocks_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow_blocks", + "columnsFrom": ["source_block_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_edges_target_block_id_workflow_blocks_id_fk": { + "name": "workflow_edges_target_block_id_workflow_blocks_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow_blocks", + "columnsFrom": ["target_block_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_execution_logs": { + "name": "workflow_execution_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state_snapshot_id": { + "name": "state_snapshot_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_duration_ms": { + "name": "total_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "execution_data": { + "name": "execution_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "cost": { + "name": "cost", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "cost_total": { + "name": "cost_total", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "models_used": { + "name": "models_used", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "files": { + "name": "files", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_execution_logs_workflow_id_idx": { + "name": "workflow_execution_logs_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_state_snapshot_id_idx": { + "name": "workflow_execution_logs_state_snapshot_id_idx", + "columns": [ + { + "expression": "state_snapshot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_deployment_version_id_idx": { + "name": "workflow_execution_logs_deployment_version_id_idx", + "columns": [ + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_trigger_idx": { + "name": "workflow_execution_logs_trigger_idx", + "columns": [ + { + "expression": "trigger", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_level_idx": { + "name": "workflow_execution_logs_level_idx", + "columns": [ + { + "expression": "level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_started_at_idx": { + "name": "workflow_execution_logs_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_execution_id_unique": { + "name": "workflow_execution_logs_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workflow_started_at_idx": { + "name": "workflow_execution_logs_workflow_started_at_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workspace_started_at_idx": { + "name": "workflow_execution_logs_workspace_started_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workspace_cost_total_idx": { + "name": "workflow_execution_logs_workspace_cost_total_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_total", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_models_used_idx": { + "name": "workflow_execution_logs_models_used_idx", + "columns": [ + { + "expression": "models_used", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "workflow_execution_logs_workspace_ended_at_id_idx": { + "name": "workflow_execution_logs_workspace_ended_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"ended_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_running_started_at_idx": { + "name": "workflow_execution_logs_running_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "status = 'running'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_execution_logs_workflow_id_workflow_id_fk": { + "name": "workflow_execution_logs_workflow_id_workflow_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workflow_execution_logs_workspace_id_workspace_id_fk": { + "name": "workflow_execution_logs_workspace_id_workspace_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_execution_logs_state_snapshot_id_workflow_execution_snapshots_id_fk": { + "name": "workflow_execution_logs_state_snapshot_id_workflow_execution_snapshots_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow_execution_snapshots", + "columnsFrom": ["state_snapshot_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "workflow_execution_logs_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_execution_logs_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_execution_snapshots": { + "name": "workflow_execution_snapshots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state_hash": { + "name": "state_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state_data": { + "name": "state_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_snapshots_workflow_id_idx": { + "name": "workflow_snapshots_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_hash_idx": { + "name": "workflow_snapshots_hash_idx", + "columns": [ + { + "expression": "state_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_workflow_hash_idx": { + "name": "workflow_snapshots_workflow_hash_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "state_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_created_at_idx": { + "name": "workflow_snapshots_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_execution_snapshots_workflow_id_workflow_id_fk": { + "name": "workflow_execution_snapshots_workflow_id_workflow_id_fk", + "tableFrom": "workflow_execution_snapshots", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_folder": { + "name": "workflow_folder", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'#6B7280'" + }, + "is_expanded": { + "name": "is_expanded", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workflow_folder_user_idx": { + "name": "workflow_folder_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_workspace_parent_idx": { + "name": "workflow_folder_workspace_parent_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_parent_sort_idx": { + "name": "workflow_folder_parent_sort_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_archived_at_idx": { + "name": "workflow_folder_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_workspace_archived_partial_idx": { + "name": "workflow_folder_workspace_archived_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_folder\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_folder_user_id_user_id_fk": { + "name": "workflow_folder_user_id_user_id_fk", + "tableFrom": "workflow_folder", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_folder_workspace_id_workspace_id_fk": { + "name": "workflow_folder_workspace_id_workspace_id_fk", + "tableFrom": "workflow_folder", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_mcp_server": { + "name": "workflow_mcp_server", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_mcp_server_workspace_id_idx": { + "name": "workflow_mcp_server_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_server_created_by_idx": { + "name": "workflow_mcp_server_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_server_deleted_at_idx": { + "name": "workflow_mcp_server_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_server_workspace_deleted_partial_idx": { + "name": "workflow_mcp_server_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_mcp_server\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_mcp_server_workspace_id_workspace_id_fk": { + "name": "workflow_mcp_server_workspace_id_workspace_id_fk", + "tableFrom": "workflow_mcp_server", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_mcp_server_created_by_user_id_fk": { + "name": "workflow_mcp_server_created_by_user_id_fk", + "tableFrom": "workflow_mcp_server", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_mcp_tool": { + "name": "workflow_mcp_tool", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_description": { + "name": "tool_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parameter_schema": { + "name": "parameter_schema", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_mcp_tool_server_id_idx": { + "name": "workflow_mcp_tool_server_id_idx", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_tool_workflow_id_idx": { + "name": "workflow_mcp_tool_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_tool_server_workflow_unique": { + "name": "workflow_mcp_tool_server_workflow_unique", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_mcp_tool\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_tool_archived_at_partial_idx": { + "name": "workflow_mcp_tool_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_mcp_tool\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_mcp_tool_server_id_workflow_mcp_server_id_fk": { + "name": "workflow_mcp_tool_server_id_workflow_mcp_server_id_fk", + "tableFrom": "workflow_mcp_tool", + "tableTo": "workflow_mcp_server", + "columnsFrom": ["server_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_mcp_tool_workflow_id_workflow_id_fk": { + "name": "workflow_mcp_tool_workflow_id_workflow_id_fk", + "tableFrom": "workflow_mcp_tool", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_schedule": { + "name": "workflow_schedule", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cron_expression": { + "name": "cron_expression", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_run_at": { + "name": "next_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_ran_at": { + "name": "last_ran_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_queued_at": { + "name": "last_queued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trigger_type": { + "name": "trigger_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'UTC'" + }, + "failed_count": { + "name": "failed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "infra_retry_count": { + "name": "infra_retry_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "last_failed_at": { + "name": "last_failed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "source_type": { + "name": "source_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'workflow'" + }, + "job_title": { + "name": "job_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lifecycle": { + "name": "lifecycle", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'persistent'" + }, + "success_condition": { + "name": "success_condition", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "max_runs": { + "name": "max_runs", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "run_count": { + "name": "run_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "source_chat_id": { + "name": "source_chat_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_task_name": { + "name": "source_task_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_user_id": { + "name": "source_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_workspace_id": { + "name": "source_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "job_history": { + "name": "job_history", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_schedule_workflow_block_deployment_unique": { + "name": "workflow_schedule_workflow_block_deployment_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_schedule\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_workflow_deployment_idx": { + "name": "workflow_schedule_workflow_deployment_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_archived_at_partial_idx": { + "name": "workflow_schedule_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_schedule\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workflow_schedule_on_source_workspace_id_source_t_c07f3bba6": { + "name": "idx_workflow_schedule_on_source_workspace_id_source_t_c07f3bba6", + "columns": [ + { + "expression": "source_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_due_workflow_idx": { + "name": "workflow_schedule_due_workflow_idx", + "columns": [ + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_schedule\".\"archived_at\" IS NULL AND \"workflow_schedule\".\"status\" NOT IN ('disabled', 'completed') AND (\"workflow_schedule\".\"source_type\" = 'workflow' OR \"workflow_schedule\".\"source_type\" IS NULL)", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_due_job_idx": { + "name": "workflow_schedule_due_job_idx", + "columns": [ + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_schedule\".\"archived_at\" IS NULL AND \"workflow_schedule\".\"status\" NOT IN ('disabled', 'completed') AND \"workflow_schedule\".\"source_type\" = 'job'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_schedule_workflow_id_workflow_id_fk": { + "name": "workflow_schedule_workflow_id_workflow_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_schedule_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_source_user_id_user_id_fk": { + "name": "workflow_schedule_source_user_id_user_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "user", + "columnsFrom": ["source_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_source_workspace_id_workspace_id_fk": { + "name": "workflow_schedule_source_workspace_id_workspace_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workspace", + "columnsFrom": ["source_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_subflows": { + "name": "workflow_subflows", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_subflows_workflow_id_idx": { + "name": "workflow_subflows_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_subflows_workflow_type_idx": { + "name": "workflow_subflows_workflow_type_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_subflows_workflow_id_workflow_id_fk": { + "name": "workflow_subflows_workflow_id_workflow_id_fk", + "tableFrom": "workflow_subflows", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace": { + "name": "workspace", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'#33C482'" + }, + "logo_url": { + "name": "logo_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_mode": { + "name": "workspace_mode", + "type": "workspace_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'grandfathered_shared'" + }, + "billed_account_user_id": { + "name": "billed_account_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "allow_personal_api_keys": { + "name": "allow_personal_api_keys", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "inbox_enabled": { + "name": "inbox_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "inbox_address": { + "name": "inbox_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "inbox_provider_id": { + "name": "inbox_provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_owner_id_idx": { + "name": "workspace_owner_id_idx", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_organization_id_idx": { + "name": "workspace_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_mode_idx": { + "name": "workspace_mode_idx", + "columns": [ + { + "expression": "workspace_mode", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_owner_id_user_id_fk": { + "name": "workspace_owner_id_user_id_fk", + "tableFrom": "workspace", + "tableTo": "user", + "columnsFrom": ["owner_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_organization_id_organization_id_fk": { + "name": "workspace_organization_id_organization_id_fk", + "tableFrom": "workspace", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspace_billed_account_user_id_user_id_fk": { + "name": "workspace_billed_account_user_id_user_id_fk", + "tableFrom": "workspace", + "tableTo": "user", + "columnsFrom": ["billed_account_user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_byok_keys": { + "name": "workspace_byok_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_api_key": { + "name": "encrypted_api_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_byok_provider_unique": { + "name": "workspace_byok_provider_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_byok_workspace_idx": { + "name": "workspace_byok_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_byok_keys_workspace_id_workspace_id_fk": { + "name": "workspace_byok_keys_workspace_id_workspace_id_fk", + "tableFrom": "workspace_byok_keys", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_byok_keys_created_by_user_id_fk": { + "name": "workspace_byok_keys_created_by_user_id_fk", + "tableFrom": "workspace_byok_keys", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_environment": { + "name": "workspace_environment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_environment_workspace_unique": { + "name": "workspace_environment_workspace_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_environment_workspace_id_workspace_id_fk": { + "name": "workspace_environment_workspace_id_workspace_id_fk", + "tableFrom": "workspace_environment", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file": { + "name": "workspace_file", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_file_workspace_id_idx": { + "name": "workspace_file_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_key_idx": { + "name": "workspace_file_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_deleted_at_idx": { + "name": "workspace_file_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_workspace_deleted_partial_idx": { + "name": "workspace_file_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_file\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_file_workspace_id_workspace_id_fk": { + "name": "workspace_file_workspace_id_workspace_id_fk", + "tableFrom": "workspace_file", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_file_uploaded_by_user_id_fk": { + "name": "workspace_file_uploaded_by_user_id_fk", + "tableFrom": "workspace_file", + "tableTo": "user", + "columnsFrom": ["uploaded_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workspace_file_key_unique": { + "name": "workspace_file_key_unique", + "nullsNotDistinct": false, + "columns": ["key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file_folders": { + "name": "workspace_file_folders", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_file_folders_workspace_parent_idx": { + "name": "workspace_file_folders_workspace_parent_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_folders_parent_sort_idx": { + "name": "workspace_file_folders_parent_sort_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_folders_deleted_at_idx": { + "name": "workspace_file_folders_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_folders_workspace_deleted_partial_idx": { + "name": "workspace_file_folders_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_file_folders\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_folders_workspace_parent_name_active_unique": { + "name": "workspace_file_folders_workspace_parent_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"parent_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_file_folders\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_file_folders_user_id_user_id_fk": { + "name": "workspace_file_folders_user_id_user_id_fk", + "tableFrom": "workspace_file_folders", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_file_folders_workspace_id_workspace_id_fk": { + "name": "workspace_file_folders_workspace_id_workspace_id_fk", + "tableFrom": "workspace_file_folders", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_file_folders_parent_id_workspace_file_folders_id_fk": { + "name": "workspace_file_folders_parent_id_workspace_file_folders_id_fk", + "tableFrom": "workspace_file_folders", + "tableTo": "workspace_file_folders", + "columnsFrom": ["parent_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_files": { + "name": "workspace_files", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "context": { + "name": "context", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "original_name": { + "name": "original_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_files_key_active_unique": { + "name": "workspace_files_key_active_unique", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_files\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_workspace_folder_name_active_unique": { + "name": "workspace_files_workspace_folder_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"folder_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "original_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_files\".\"deleted_at\" IS NULL AND \"workspace_files\".\"context\" = 'workspace' AND \"workspace_files\".\"workspace_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_chat_display_name_unique": { + "name": "workspace_files_chat_display_name_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "display_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_files\".\"context\" = 'mothership' AND \"workspace_files\".\"chat_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_key_idx": { + "name": "workspace_files_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_user_id_idx": { + "name": "workspace_files_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_workspace_id_idx": { + "name": "workspace_files_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_folder_id_idx": { + "name": "workspace_files_folder_id_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_context_idx": { + "name": "workspace_files_context_idx", + "columns": [ + { + "expression": "context", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_chat_id_idx": { + "name": "workspace_files_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_deleted_at_idx": { + "name": "workspace_files_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_workspace_deleted_partial_idx": { + "name": "workspace_files_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_files\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_files_user_id_user_id_fk": { + "name": "workspace_files_user_id_user_id_fk", + "tableFrom": "workspace_files", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_files_workspace_id_workspace_id_fk": { + "name": "workspace_files_workspace_id_workspace_id_fk", + "tableFrom": "workspace_files", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_files_folder_id_workspace_file_folders_id_fk": { + "name": "workspace_files_folder_id_workspace_file_folders_id_fk", + "tableFrom": "workspace_files", + "tableTo": "workspace_file_folders", + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspace_files_chat_id_copilot_chats_id_fk": { + "name": "workspace_files_chat_id_copilot_chats_id_fk", + "tableFrom": "workspace_files", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_notification_delivery": { + "name": "workspace_notification_delivery", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "subscription_id": { + "name": "subscription_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "notification_delivery_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_attempt_at": { + "name": "last_attempt_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "next_attempt_at": { + "name": "next_attempt_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "response_status": { + "name": "response_status", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "response_body": { + "name": "response_body", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_notification_delivery_subscription_id_idx": { + "name": "workspace_notification_delivery_subscription_id_idx", + "columns": [ + { + "expression": "subscription_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_notification_delivery_execution_id_idx": { + "name": "workspace_notification_delivery_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_notification_delivery_status_idx": { + "name": "workspace_notification_delivery_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_notification_delivery_next_attempt_idx": { + "name": "workspace_notification_delivery_next_attempt_idx", + "columns": [ + { + "expression": "next_attempt_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_notification_delivery_subscription_id_workspace_notification_subscription_id_fk": { + "name": "workspace_notification_delivery_subscription_id_workspace_notification_subscription_id_fk", + "tableFrom": "workspace_notification_delivery", + "tableTo": "workspace_notification_subscription", + "columnsFrom": ["subscription_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_notification_delivery_workflow_id_workflow_id_fk": { + "name": "workspace_notification_delivery_workflow_id_workflow_id_fk", + "tableFrom": "workspace_notification_delivery", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_notification_subscription": { + "name": "workspace_notification_subscription", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "notification_type": { + "name": "notification_type", + "type": "notification_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "workflow_ids": { + "name": "workflow_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "all_workflows": { + "name": "all_workflows", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "level_filter": { + "name": "level_filter", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "ARRAY['info', 'error']::text[]" + }, + "trigger_filter": { + "name": "trigger_filter", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "ARRAY['api', 'webhook', 'schedule', 'manual', 'chat']::text[]" + }, + "include_final_output": { + "name": "include_final_output", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "include_trace_spans": { + "name": "include_trace_spans", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "include_rate_limits": { + "name": "include_rate_limits", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "include_usage_data": { + "name": "include_usage_data", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "webhook_config": { + "name": "webhook_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "email_recipients": { + "name": "email_recipients", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "slack_config": { + "name": "slack_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "alert_config": { + "name": "alert_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "last_alert_at": { + "name": "last_alert_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_notification_workspace_id_idx": { + "name": "workspace_notification_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_notification_active_idx": { + "name": "workspace_notification_active_idx", + "columns": [ + { + "expression": "active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_notification_type_idx": { + "name": "workspace_notification_type_idx", + "columns": [ + { + "expression": "notification_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_notification_subscription_workspace_id_workspace_id_fk": { + "name": "workspace_notification_subscription_workspace_id_workspace_id_fk", + "tableFrom": "workspace_notification_subscription", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_notification_subscription_created_by_user_id_fk": { + "name": "workspace_notification_subscription_created_by_user_id_fk", + "tableFrom": "workspace_notification_subscription", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.a2a_task_status": { + "name": "a2a_task_status", + "schema": "public", + "values": [ + "submitted", + "working", + "input-required", + "completed", + "failed", + "canceled", + "rejected", + "auth-required", + "unknown" + ] + }, + "public.academy_cert_status": { + "name": "academy_cert_status", + "schema": "public", + "values": ["active", "revoked", "expired"] + }, + "public.billing_blocked_reason": { + "name": "billing_blocked_reason", + "schema": "public", + "values": ["payment_failed", "dispute"] + }, + "public.billing_entity_type": { + "name": "billing_entity_type", + "schema": "public", + "values": ["user", "organization"] + }, + "public.chat_type": { + "name": "chat_type", + "schema": "public", + "values": ["mothership", "copilot"] + }, + "public.copilot_async_tool_status": { + "name": "copilot_async_tool_status", + "schema": "public", + "values": ["pending", "running", "completed", "failed", "cancelled", "delivered"] + }, + "public.copilot_run_status": { + "name": "copilot_run_status", + "schema": "public", + "values": ["active", "paused_waiting_for_tool", "resuming", "complete", "error", "cancelled"] + }, + "public.credential_member_role": { + "name": "credential_member_role", + "schema": "public", + "values": ["admin", "member"] + }, + "public.credential_member_status": { + "name": "credential_member_status", + "schema": "public", + "values": ["active", "pending", "revoked"] + }, + "public.credential_set_invitation_status": { + "name": "credential_set_invitation_status", + "schema": "public", + "values": ["pending", "accepted", "expired", "cancelled"] + }, + "public.credential_set_member_status": { + "name": "credential_set_member_status", + "schema": "public", + "values": ["active", "pending", "revoked"] + }, + "public.credential_type": { + "name": "credential_type", + "schema": "public", + "values": ["oauth", "env_workspace", "env_personal", "service_account"] + }, + "public.data_drain_cadence": { + "name": "data_drain_cadence", + "schema": "public", + "values": ["hourly", "daily"] + }, + "public.data_drain_destination": { + "name": "data_drain_destination", + "schema": "public", + "values": ["s3", "gcs", "azure_blob", "datadog", "bigquery", "snowflake", "webhook"] + }, + "public.data_drain_run_status": { + "name": "data_drain_run_status", + "schema": "public", + "values": ["running", "success", "failed"] + }, + "public.data_drain_run_trigger": { + "name": "data_drain_run_trigger", + "schema": "public", + "values": ["cron", "manual"] + }, + "public.data_drain_source": { + "name": "data_drain_source", + "schema": "public", + "values": ["workflow_logs", "job_logs", "audit_logs", "copilot_chats", "copilot_runs"] + }, + "public.execution_large_value_reference_source": { + "name": "execution_large_value_reference_source", + "schema": "public", + "values": ["execution_log", "paused_snapshot"] + }, + "public.invitation_kind": { + "name": "invitation_kind", + "schema": "public", + "values": ["organization", "workspace"] + }, + "public.invitation_membership_intent": { + "name": "invitation_membership_intent", + "schema": "public", + "values": ["internal", "external"] + }, + "public.invitation_status": { + "name": "invitation_status", + "schema": "public", + "values": ["pending", "accepted", "rejected", "cancelled", "expired"] + }, + "public.notification_delivery_status": { + "name": "notification_delivery_status", + "schema": "public", + "values": ["pending", "in_progress", "success", "failed"] + }, + "public.notification_type": { + "name": "notification_type", + "schema": "public", + "values": ["webhook", "email", "slack"] + }, + "public.permission_type": { + "name": "permission_type", + "schema": "public", + "values": ["admin", "write", "read"] + }, + "public.usage_log_category": { + "name": "usage_log_category", + "schema": "public", + "values": ["model", "fixed", "tool"] + }, + "public.usage_log_source": { + "name": "usage_log_source", + "schema": "public", + "values": [ + "workflow", + "wand", + "copilot", + "workspace-chat", + "mcp_copilot", + "mothership_block", + "knowledge-base", + "voice-input", + "enrichment" + ] + }, + "public.workspace_mode": { + "name": "workspace_mode", + "schema": "public", + "values": ["personal", "organization", "grandfathered_shared"] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/db/migrations/meta/_journal.json b/packages/db/migrations/meta/_journal.json index 55ca91ceaf0..1ebd77b350c 100644 --- a/packages/db/migrations/meta/_journal.json +++ b/packages/db/migrations/meta/_journal.json @@ -1611,6 +1611,13 @@ "when": 1781027249389, "tag": "0230_thick_stranger", "breakpoints": true + }, + { + "idx": 231, + "version": "7", + "when": 1781055027957, + "tag": "0231_table_jobs_and_keyset", + "breakpoints": true } ] } diff --git a/packages/db/schema.ts b/packages/db/schema.ts index a24c15d8c79..1cfa0ac5f7e 100644 --- a/packages/db/schema.ts +++ b/packages/db/schema.ts @@ -3166,16 +3166,6 @@ export const userTableDefinitions = pgTable( maxRows: integer('max_rows').notNull().default(10000), rowCount: integer('row_count').notNull().default(0), archivedAt: timestamp('archived_at'), - /** - * Async-import state. NULL = a normal table (never imported in the background). - * `'importing'` hides rows until the load completes; `'ready'` reveals them; - * `'failed'` surfaces a partial import. See `apps/sim/lib/table/import-runner.ts`. - */ - importStatus: text('import_status'), - importId: text('import_id'), - importError: text('import_error'), - importRowsProcessed: integer('import_rows_processed').notNull().default(0), - importStartedAt: timestamp('import_started_at'), createdBy: text('created_by') .notNull() .references(() => user.id, { onDelete: 'cascade' }), @@ -3226,7 +3216,20 @@ export const userTableRows = pgTable( }, (table) => ({ tableIdIdx: index('user_table_rows_table_id_idx').on(table.tableId), - dataGinIdx: index('user_table_rows_data_gin_idx').using('gin', table.data), + /** + * Tenant-scoped containment index (requires the `btree_gin` extension, + * created in migration 0232). A plain GIN on `data` matches `@>` candidates + * across every tenant sharing this relation — a hot value in someone else's + * table inflates everyone's scans (measured 1.07M candidates fetched for a + * 33k-row match). Leading with `table_id` intersects inside the index, and + * `jsonb_path_ops` indexes only containment paths: rare-equality probe + * 326ms → 17ms, and the index is smaller than the one it replaces. + */ + dataGinIdx: index('user_table_rows_tenant_data_gin_idx').using( + 'gin', + table.tableId, + sql`${table.data} jsonb_path_ops` + ), workspaceTableIdx: index('user_table_rows_workspace_table_idx').on( table.workspaceId, table.tableId @@ -3237,6 +3240,56 @@ export const userTableRows = pgTable( table.orderKey, table.id ), + /** + * Keyset pagination by id within one table (the delete-job worker's page walk). Without it + * the planner scans the global pkey in id order, filtering out every other table's rows — + * O(all rows) per page. + */ + tableIdIdIdx: index('user_table_rows_table_id_id_idx').on(table.tableId, table.id), + }) +) + +/** + * Background data-mutation jobs on a user table (CSV import, bulk filtered delete). One row per + * job. A detached worker streams progress into `rows_processed` and flips `status` to a terminal + * state; cancel flips `status` to `'canceled'` and the worker bails at its next ownership check. + * + * The partial-unique index on `table_id WHERE status = 'running'` is the concurrency gate: at most + * one running job per table, so a second import, or an import + delete, can't write into the same + * table at once. Distinct from `table_run_dispatches` — that fans workflow runs across rows via + * trigger.dev; this mutates row data directly. + */ +export const tableJobs = pgTable( + 'table_jobs', + { + id: text('id').primaryKey(), + tableId: text('table_id') + .notNull() + .references(() => userTableDefinitions.id, { onDelete: 'cascade' }), + workspaceId: text('workspace_id') + .notNull() + .references(() => workspace.id, { onDelete: 'cascade' }), + /** `'import'` | `'delete'`. */ + type: text('type').notNull(), + /** `'running'` → `'ready'` | `'failed'` | `'canceled'`. */ + status: text('status').notNull().default('running'), + /** Type-specific descriptor (e.g. delete filter/exclusions). Nullable; reserved for future + * resumability — today's workers carry their payload in-process via `runDetached`. */ + payload: jsonb('payload'), + rowsProcessed: integer('rows_processed').notNull().default(0), + error: text('error'), + startedAt: timestamp('started_at').notNull().defaultNow(), + updatedAt: timestamp('updated_at').notNull().defaultNow(), + completedAt: timestamp('completed_at'), + }, + (table) => ({ + /** One running write-job (import/delete/backfill) per table. Exports are read-only and + * excluded, so they can run alongside any other job. */ + oneActivePerTable: uniqueIndex('table_jobs_one_active_per_table') + .on(table.tableId) + .where(sql`${table.status} = 'running' AND ${table.type} <> 'export'`), + watchdogIdx: index('table_jobs_watchdog_idx').on(table.status, table.updatedAt), + tableStartedIdx: index('table_jobs_table_started_idx').on(table.tableId, table.startedAt), }) ) diff --git a/packages/testing/src/mocks/schema.mock.ts b/packages/testing/src/mocks/schema.mock.ts index 5836a652851..1c077068e73 100644 --- a/packages/testing/src/mocks/schema.mock.ts +++ b/packages/testing/src/mocks/schema.mock.ts @@ -1138,6 +1138,19 @@ export const schemaMock = { updatedAt: 'updatedAt', createdBy: 'createdBy', }, + tableJobs: { + id: 'id', + tableId: 'tableId', + workspaceId: 'workspaceId', + type: 'type', + status: 'status', + payload: 'payload', + rowsProcessed: 'rowsProcessed', + error: 'error', + startedAt: 'startedAt', + updatedAt: 'updatedAt', + completedAt: 'completedAt', + }, tableRowExecutions: { tableId: 'tableId', rowId: 'rowId', diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts index b639bedd2b0..18f8ad710f9 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: 804, - zodRoutes: 804, + totalRoutes: 807, + zodRoutes: 807, nonZodRoutes: 0, } as const