From c173eb9829f33f74fe76729d7b158940b7e86e47 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Fri, 24 Jul 2026 21:47:21 -0700 Subject: [PATCH 01/15] feat(tables): per-table mutation locks (schema/insert/update/delete) Adds four independent, admin-only locks to a table so a workspace can make it append-only, read-only, or schema-frozen. Enforced at the lib/table service layer rather than the routes, so the API, workflow blocks, and Mothership (which calls the services directly) are all covered by one assert. Violations return 423; changing a lock requires workspace admin and returns 403. --- .../app/api/table/[tableId]/columns/route.ts | 14 +- .../api/table/[tableId]/delete-async/route.ts | 6 + .../app/api/table/[tableId]/groups/route.ts | 9 +- .../app/api/table/[tableId]/import/route.ts | 3 + apps/sim/app/api/table/[tableId]/route.ts | 57 +++++- .../api/table/[tableId]/rows/[rowId]/route.ts | 2 +- .../sim/app/api/table/[tableId]/rows/route.ts | 1 + apps/sim/app/api/table/route.ts | 2 + apps/sim/app/api/table/utils.ts | 24 +++ .../api/v1/tables/[tableId]/columns/route.ts | 13 +- apps/sim/app/api/v1/tables/[tableId]/route.ts | 10 +- .../v1/tables/[tableId]/rows/[rowId]/route.ts | 28 ++- .../app/api/v1/tables/[tableId]/rows/route.ts | 1 + .../v1/tables/[tableId]/rows/upsert/route.ts | 4 +- apps/sim/app/api/v1/tables/route.ts | 2 + .../tables/[tableId]/components/index.ts | 2 + .../components/lock-settings-modal/index.ts | 1 + .../lock-settings-modal.tsx | 122 ++++++++++++ .../new-column-dropdown.tsx | 61 ++++-- .../components/table-grid/table-grid.tsx | 109 ++++++++--- .../components/table-locked-modal/index.ts | 1 + .../table-locked-modal/table-locked-modal.tsx | 125 +++++++++++++ .../[tableId]/hooks/use-table-event-stream.ts | 9 + .../tables/[tableId]/lock-copy.ts | 67 +++++++ .../[workspaceId]/tables/[tableId]/table.tsx | 94 ++++++++-- .../lib/copy/copy-resources.ts | 8 + apps/sim/hooks/queries/tables.test.ts | 2 + apps/sim/hooks/queries/tables.ts | 109 ++++++++++- apps/sim/hooks/use-table-undo.test.ts | 9 +- apps/sim/hooks/use-table-undo.ts | 89 +++++++++ apps/sim/lib/api/contracts/tables.ts | 50 +++++ .../lib/copilot/request/tools/tables.test.ts | 1 + .../copilot/tools/server/table/user-table.ts | 17 +- apps/sim/lib/core/config/env.ts | 1 + apps/sim/lib/core/config/feature-flags.ts | 9 + .../table/__tests__/find-row-matches.test.ts | 1 + .../lib/table/__tests__/lock-order.test.ts | 1 + .../service-filter-threading.test.ts | 1 + .../lib/table/__tests__/update-row.test.ts | 1 + apps/sim/lib/table/cell-write.test.ts | 1 + apps/sim/lib/table/columns/service.ts | 8 + apps/sim/lib/table/delete-runner.test.ts | 18 +- apps/sim/lib/table/delete-runner.ts | 10 +- apps/sim/lib/table/events.ts | 9 + apps/sim/lib/table/import-data.ts | 16 +- apps/sim/lib/table/import-runner.ts | 4 +- apps/sim/lib/table/mutation-locks.test.ts | 111 +++++++++++ apps/sim/lib/table/mutation-locks.ts | 176 ++++++++++++++++++ apps/sim/lib/table/rows/ordering.ts | 15 +- apps/sim/lib/table/rows/service.ts | 47 ++++- apps/sim/lib/table/service.ts | 132 ++++++++++++- apps/sim/lib/table/types.ts | 31 +++ apps/sim/lib/table/update-runner.test.ts | 6 +- apps/sim/lib/table/update-runner.ts | 9 +- apps/sim/lib/table/workflow-groups/service.ts | 12 ++ .../migrations/0270_table_mutation_locks.sql | 4 + packages/db/migrations/meta/_journal.json | 7 + packages/db/schema.ts | 15 ++ packages/testing/src/mocks/schema.mock.ts | 5 + 59 files changed, 1579 insertions(+), 123 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/lock-settings-modal/index.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/lock-settings-modal/lock-settings-modal.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-locked-modal/index.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-locked-modal/table-locked-modal.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/tables/[tableId]/lock-copy.ts create mode 100644 apps/sim/lib/table/mutation-locks.test.ts create mode 100644 apps/sim/lib/table/mutation-locks.ts create mode 100644 packages/db/migrations/0270_table_mutation_locks.sql diff --git a/apps/sim/app/api/table/[tableId]/columns/route.ts b/apps/sim/app/api/table/[tableId]/columns/route.ts index c749ddbbff3..227c1422b04 100644 --- a/apps/sim/app/api/table/[tableId]/columns/route.ts +++ b/apps/sim/app/api/table/[tableId]/columns/route.ts @@ -19,7 +19,13 @@ import { updateColumnType, } from '@/lib/table' import { columnMatchesRef } from '@/lib/table/column-keys' -import { accessError, checkAccess, normalizeColumn, rootErrorMessage } from '@/app/api/table/utils' +import { + accessError, + checkAccess, + normalizeColumn, + rootErrorMessage, + tableLockErrorResponse, +} from '@/app/api/table/utils' const logger = createLogger('TableColumnsAPI') @@ -61,6 +67,8 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Colum }, }) } catch (error) { + const lockError = tableLockErrorResponse(error) + if (lockError) return lockError if (isZodError(error)) { return validationErrorResponse(error, 'Invalid request data') } @@ -190,6 +198,8 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Colu }, }) } catch (error) { + const lockError = tableLockErrorResponse(error) + if (lockError) return lockError if (isZodError(error)) { return validationErrorResponse(error, 'Invalid request data') } @@ -255,6 +265,8 @@ export const DELETE = withRouteHandler( }, }) } catch (error) { + const lockError = tableLockErrorResponse(error) + if (lockError) return lockError if (isZodError(error)) { return validationErrorResponse(error, 'Invalid request data') } diff --git a/apps/sim/app/api/table/[tableId]/delete-async/route.ts b/apps/sim/app/api/table/[tableId]/delete-async/route.ts index 9a525eb54fe..236eb556240 100644 --- a/apps/sim/app/api/table/[tableId]/delete-async/route.ts +++ b/apps/sim/app/api/table/[tableId]/delete-async/route.ts @@ -10,6 +10,7 @@ import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { markTableDeleteFailed, runTableDelete } from '@/lib/table/delete-runner' import { markTableJobRunning, releaseJobClaim } from '@/lib/table/jobs/service' +import { assertRowDelete } from '@/lib/table/mutation-locks' import type { TableDeleteJobPayload } from '@/lib/table/types' import { accessError, checkAccess, tableFilterError } from '@/app/api/table/utils' @@ -55,6 +56,11 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro return NextResponse.json({ error: 'Cannot delete from an archived table' }, { status: 400 }) } + // Gate the delete lock at enqueue: an admitted job runs to completion, so the + // lock must be checked before the job is created (the worker is a trusted + // continuation and does not re-check). + assertRowDelete(table) + // 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 diff --git a/apps/sim/app/api/table/[tableId]/groups/route.ts b/apps/sim/app/api/table/[tableId]/groups/route.ts index 84aecb13c0c..8b5de09bee0 100644 --- a/apps/sim/app/api/table/[tableId]/groups/route.ts +++ b/apps/sim/app/api/table/[tableId]/groups/route.ts @@ -15,7 +15,12 @@ import { deleteWorkflowGroup, updateWorkflowGroup, } from '@/lib/table/workflow-groups/service' -import { accessError, checkAccess, normalizeColumn } from '@/app/api/table/utils' +import { + accessError, + checkAccess, + normalizeColumn, + tableLockErrorResponse, +} from '@/app/api/table/utils' const logger = createLogger('TableWorkflowGroupsAPI') @@ -47,6 +52,8 @@ async function validateWorkflowInWorkspace( * share this mapper instead of repeating the if-chain three times. */ function mapWorkflowGroupError(error: unknown, fallbackMessage: string): NextResponse { + const lockError = tableLockErrorResponse(error) + if (lockError) return lockError if (error instanceof Error) { const msg = error.message if (msg === 'Table not found' || msg.includes('not found')) { diff --git a/apps/sim/app/api/table/[tableId]/import/route.ts b/apps/sim/app/api/table/[tableId]/import/route.ts index 1d6482390fd..984c1457f43 100644 --- a/apps/sim/app/api/table/[tableId]/import/route.ts +++ b/apps/sim/app/api/table/[tableId]/import/route.ts @@ -44,6 +44,7 @@ import { checkAccess, csvProxyBodyCapResponse, multipartErrorResponse, + tableLockErrorResponse, } from '@/app/api/table/utils' const logger = createLogger('TableImportCSVExisting') @@ -408,6 +409,8 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro throw err } } catch (error) { + const lockError = tableLockErrorResponse(error) + if (lockError) return lockError if (isMultipartError(error)) return multipartErrorResponse(error) const message = toError(error).message diff --git a/apps/sim/app/api/table/[tableId]/route.ts b/apps/sim/app/api/table/[tableId]/route.ts index 5d0069fa570..ada92283e15 100644 --- a/apps/sim/app/api/table/[tableId]/route.ts +++ b/apps/sim/app/api/table/[tableId]/route.ts @@ -1,15 +1,28 @@ import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' -import { getTableQuerySchema, renameTableContract } from '@/lib/api/contracts/tables' +import { getTableQuerySchema, updateTableContract } from '@/lib/api/contracts/tables' import { isZodError, parseRequest, validationErrorResponse } from '@/lib/api/server/validation' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' +import { isFeatureEnabled } from '@/lib/core/config/feature-flags' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { captureServerEvent } from '@/lib/posthog/server' -import { deleteTable, renameTable, TableConflictError, type TableSchema } from '@/lib/table' +import { + deleteTable, + getTableById, + renameTable, + TableConflictError, + type TableSchema, + updateTableLocks, +} from '@/lib/table' import { getWorkspaceTableLimits } from '@/lib/table/billing' -import { accessError, checkAccess, normalizeColumn } from '@/app/api/table/utils' +import { + accessError, + checkAccess, + normalizeColumn, + tableLockErrorResponse, +} from '@/app/api/table/utils' const logger = createLogger('TableDetailAPI') @@ -65,6 +78,7 @@ export const GET = withRouteHandler(async (request: NextRequest, { params }: Tab metadata: table.metadata ?? null, rowCount: table.rowCount, maxRows: maxRowsPerTable, + locks: table.locks, createdAt: table.createdAt instanceof Date ? table.createdAt.toISOString() @@ -104,7 +118,7 @@ export const PATCH = withRouteHandler( } const parsed = await parseRequest( - renameTableContract, + updateTableContract, request, { params }, { @@ -116,6 +130,8 @@ export const PATCH = withRouteHandler( const { tableId } = parsed.data.params const validated = parsed.data.body + // `write` is the floor for either operation; a `locks` change additionally + // requires `admin` (checked below), matching the workflow-lock precedent. const result = await checkAccess(tableId, authResult.userId, 'write') if (!result.ok) return accessError(result, requestId, tableId) @@ -125,20 +141,45 @@ export const PATCH = withRouteHandler( return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) } - const updated = await renameTable(tableId, validated.name, requestId, authResult.userId) + if (validated.locks !== undefined) { + if (!(await isFeatureEnabled('table-locks'))) { + return NextResponse.json({ error: 'Table locks are not enabled' }, { status: 403 }) + } + const adminResult = await checkAccess(tableId, authResult.userId, 'admin') + if (!adminResult.ok) { + return NextResponse.json( + { error: 'Admin access required to change table locks' }, + { status: 403 } + ) + } + await updateTableLocks(tableId, validated.locks, authResult.userId, requestId) + } + + if (validated.name !== undefined) { + await renameTable(tableId, validated.name, requestId, authResult.userId) + } + + // Re-read so the response reflects both a rename and a lock change. + const updated = await getTableById(tableId) + if (!updated) { + return NextResponse.json({ error: 'Table not found' }, { status: 404 }) + } return NextResponse.json({ success: true, data: { table: updated }, }) } catch (error) { + const lockError = tableLockErrorResponse(error) + if (lockError) return lockError + if (error instanceof TableConflictError) { return NextResponse.json({ error: error.message }, { status: 409 }) } - logger.error(`[${requestId}] Error renaming table:`, error) + logger.error(`[${requestId}] Error updating table:`, error) return NextResponse.json( - { error: getErrorMessage(error, 'Failed to rename table') }, + { error: getErrorMessage(error, 'Failed to update table') }, { status: 500 } ) } @@ -188,6 +229,8 @@ export const DELETE = withRouteHandler( }, }) } catch (error) { + const lockError = tableLockErrorResponse(error) + if (lockError) return lockError if (isZodError(error)) { return validationErrorResponse(error) } 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 b1865223f83..0a1f0d56bd9 100644 --- a/apps/sim/app/api/table/[tableId]/rows/[rowId]/route.ts +++ b/apps/sim/app/api/table/[tableId]/rows/[rowId]/route.ts @@ -211,7 +211,7 @@ export const DELETE = withRouteHandler(async (request: NextRequest, context: Row return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) } - await deleteRow(tableId, rowId, validated.workspaceId, requestId) + await deleteRow(table, rowId, requestId) return NextResponse.json({ success: true, diff --git a/apps/sim/app/api/table/[tableId]/rows/route.ts b/apps/sim/app/api/table/[tableId]/rows/route.ts index e200220ea11..5c73dd8dff1 100644 --- a/apps/sim/app/api/table/[tableId]/rows/route.ts +++ b/apps/sim/app/api/table/[tableId]/rows/route.ts @@ -433,6 +433,7 @@ export const DELETE = withRouteHandler( if (validated.rowIds) { const result = await deleteRowsByIds( + table, { tableId, rowIds: validated.rowIds, workspaceId: validated.workspaceId }, requestId ) diff --git a/apps/sim/app/api/table/route.ts b/apps/sim/app/api/table/route.ts index 45b530db29a..7506a3f521d 100644 --- a/apps/sim/app/api/table/route.ts +++ b/apps/sim/app/api/table/route.ts @@ -127,6 +127,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { }, rowCount: table.rowCount, maxRows: table.maxRows, + locks: table.locks, createdAt: table.createdAt instanceof Date ? table.createdAt.toISOString() @@ -206,6 +207,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { }, rowCount: t.rowCount, maxRows: t.maxRows, + locks: t.locks, workspaceId: t.workspaceId, createdBy: t.createdBy, createdAt: t.createdAt instanceof Date ? t.createdAt.toISOString() : String(t.createdAt), diff --git a/apps/sim/app/api/table/utils.ts b/apps/sim/app/api/table/utils.ts index 0b2c07ba1f5..f3c62d56022 100644 --- a/apps/sim/app/api/table/utils.ts +++ b/apps/sim/app/api/table/utils.ts @@ -11,8 +11,27 @@ import type { MultipartError } from '@/lib/core/utils/multipart' 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 { TableLockedError } from '@/lib/table/mutation-locks' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' +/** + * Maps a {@link TableLockedError} thrown by the service layer to a 423 response + * carrying `{ error, lock }`; returns `null` for any other error so the caller + * falls through to its existing handling. Call this as the FIRST statement of a + * table route's catch block — otherwise `rowWriteErrorResponse` (and the other + * substring funnels) turn the lock error into a generic 500. + * + * The body deliberately omits a `details` array: the client's `isValidationError` + * treats any `ApiClientError` with array-valued `details` as a field-validation + * error and swallows its toast, so a lock rejection must not carry one. + */ +export function tableLockErrorResponse(error: unknown): NextResponse | null { + if (error instanceof TableLockedError) { + return NextResponse.json({ error: error.message, lock: error.lock }, { status: 423 }) + } + return null +} + /** * 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 @@ -78,6 +97,11 @@ const ROW_WRITE_ERROR_PATTERNS = [ * unrecognized and the caller should log it and return its generic 500. */ export function rowWriteErrorResponse(error: unknown): NextResponse | null { + // A lock violation is a 423, not a 400/500 — check before the pattern match, + // which would otherwise let it fall through to the caller's generic 500. + const lockResponse = tableLockErrorResponse(error) + if (lockResponse) return lockResponse + const message = rootErrorMessage(error) if (ROW_WRITE_ERROR_PATTERNS.some((p) => message.includes(p)) || /^Row .+?:/.test(message)) { diff --git a/apps/sim/app/api/v1/tables/[tableId]/columns/route.ts b/apps/sim/app/api/v1/tables/[tableId]/columns/route.ts index 55b4e0a81d8..46723538c7c 100644 --- a/apps/sim/app/api/v1/tables/[tableId]/columns/route.ts +++ b/apps/sim/app/api/v1/tables/[tableId]/columns/route.ts @@ -18,7 +18,12 @@ import { updateColumnType, } from '@/lib/table' import { columnMatchesRef } from '@/lib/table/column-keys' -import { accessError, checkAccess, normalizeColumn } from '@/app/api/table/utils' +import { + accessError, + checkAccess, + normalizeColumn, + tableLockErrorResponse, +} from '@/app/api/table/utils' import { checkRateLimit, checkWorkspaceScope, @@ -84,6 +89,8 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Colum }, }) } catch (error) { + const lockError = tableLockErrorResponse(error) + if (lockError) return lockError const validationResponse = validationErrorResponseFromError(error) if (validationResponse) return validationResponse @@ -231,6 +238,8 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Colu }, }) } catch (error) { + const lockError = tableLockErrorResponse(error) + if (lockError) return lockError const validationResponse = validationErrorResponseFromError(error) if (validationResponse) return validationResponse @@ -312,6 +321,8 @@ export const DELETE = withRouteHandler( }, }) } catch (error) { + const lockError = tableLockErrorResponse(error) + if (lockError) return lockError const validationResponse = validationErrorResponseFromError(error) if (validationResponse) return validationResponse diff --git a/apps/sim/app/api/v1/tables/[tableId]/route.ts b/apps/sim/app/api/v1/tables/[tableId]/route.ts index a64831c857c..c06492d02b7 100644 --- a/apps/sim/app/api/v1/tables/[tableId]/route.ts +++ b/apps/sim/app/api/v1/tables/[tableId]/route.ts @@ -6,7 +6,12 @@ import { parseRequest } from '@/lib/api/server' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { deleteTable, type TableSchema } from '@/lib/table' -import { accessError, checkAccess, normalizeColumn } from '@/app/api/table/utils' +import { + accessError, + checkAccess, + normalizeColumn, + tableLockErrorResponse, +} from '@/app/api/table/utils' import { checkRateLimit, checkWorkspaceScope, @@ -77,6 +82,7 @@ export const GET = withRouteHandler(async (request: NextRequest, context: TableR }, rowCount: table.rowCount, maxRows: table.maxRows, + locks: table.locks, createdAt: table.createdAt instanceof Date ? table.createdAt.toISOString() @@ -153,6 +159,8 @@ export const DELETE = withRouteHandler(async (request: NextRequest, context: Tab }, }) } catch (error) { + const lockError = tableLockErrorResponse(error) + if (lockError) return lockError logger.error(`[${requestId}] Error deleting table:`, error) return NextResponse.json({ error: 'Failed to delete table' }, { status: 500 }) } diff --git a/apps/sim/app/api/v1/tables/[tableId]/rows/[rowId]/route.ts b/apps/sim/app/api/v1/tables/[tableId]/rows/[rowId]/route.ts index c7040a0239f..2a7ea2fe7a5 100644 --- a/apps/sim/app/api/v1/tables/[tableId]/rows/[rowId]/route.ts +++ b/apps/sim/app/api/v1/tables/[tableId]/rows/[rowId]/route.ts @@ -13,10 +13,10 @@ import { parseRequest, validationErrorResponseFromError } from '@/lib/api/server import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import type { RowData, TableSchema } from '@/lib/table' -import { updateRow } from '@/lib/table' +import { deleteRow, updateRow } from '@/lib/table' import { namedRowMapper } from '@/lib/table/cell-format' import { buildIdByName, rowDataNameToId } from '@/lib/table/column-keys' -import { accessError, checkAccess } from '@/app/api/table/utils' +import { accessError, checkAccess, tableLockErrorResponse } from '@/app/api/table/utils' import { checkRateLimit, checkWorkspaceScope, @@ -179,6 +179,8 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: RowR }, }) } catch (error) { + const lockError = tableLockErrorResponse(error) + if (lockError) return lockError const validationResponse = validationErrorResponseFromError(error) if (validationResponse) return validationResponse @@ -232,20 +234,9 @@ export const DELETE = withRouteHandler(async (request: NextRequest, context: Row return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) } - const [deletedRow] = await db - .delete(userTableRows) - .where( - and( - eq(userTableRows.id, rowId), - eq(userTableRows.tableId, tableId), - eq(userTableRows.workspaceId, workspaceId) - ) - ) - .returning({ id: userTableRows.id }) - - if (!deletedRow) { - return NextResponse.json({ error: 'Row not found' }, { status: 404 }) - } + // Route through the service (not a raw `db.delete`) so the delete lock is + // enforced — the raw path would return 200 on a locked table. + await deleteRow(result.table, rowId, requestId) return NextResponse.json({ success: true, @@ -255,6 +246,11 @@ export const DELETE = withRouteHandler(async (request: NextRequest, context: Row }, }) } catch (error) { + const lockError = tableLockErrorResponse(error) + if (lockError) return lockError + if (error instanceof Error && error.message === 'Row not found') { + return NextResponse.json({ error: 'Row not found' }, { status: 404 }) + } logger.error(`[${requestId}] Error deleting row:`, error) return NextResponse.json({ error: 'Failed to delete row' }, { status: 500 }) } 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 55bfeec39b3..d0e37376cca 100644 --- a/apps/sim/app/api/v1/tables/[tableId]/rows/route.ts +++ b/apps/sim/app/api/v1/tables/[tableId]/rows/route.ts @@ -427,6 +427,7 @@ export const DELETE = withRouteHandler( if (validated.rowIds) { const result = await deleteRowsByIds( + table, { tableId, rowIds: validated.rowIds, workspaceId: validated.workspaceId }, requestId ) diff --git a/apps/sim/app/api/v1/tables/[tableId]/rows/upsert/route.ts b/apps/sim/app/api/v1/tables/[tableId]/rows/upsert/route.ts index d35e34646aa..1df6b4b2384 100644 --- a/apps/sim/app/api/v1/tables/[tableId]/rows/upsert/route.ts +++ b/apps/sim/app/api/v1/tables/[tableId]/rows/upsert/route.ts @@ -9,7 +9,7 @@ import type { RowData, TableSchema } from '@/lib/table' import { upsertRow } from '@/lib/table' import { namedRowMapper } from '@/lib/table/cell-format' import { buildIdByName, rowDataNameToId } from '@/lib/table/column-keys' -import { accessError, checkAccess } from '@/app/api/table/utils' +import { accessError, checkAccess, tableLockErrorResponse } from '@/app/api/table/utils' import { checkRateLimit, checkWorkspaceScope, @@ -92,6 +92,8 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Upser }, }) } catch (error) { + const lockError = tableLockErrorResponse(error) + if (lockError) return lockError const validationResponse = validationErrorResponseFromError(error) if (validationResponse) return validationResponse diff --git a/apps/sim/app/api/v1/tables/route.ts b/apps/sim/app/api/v1/tables/route.ts index aaf113a3762..441f4fc1413 100644 --- a/apps/sim/app/api/v1/tables/route.ts +++ b/apps/sim/app/api/v1/tables/route.ts @@ -53,6 +53,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { }, rowCount: t.rowCount, maxRows: t.maxRows, + locks: t.locks, createdAt: t.createdAt instanceof Date ? t.createdAt.toISOString() : String(t.createdAt), updatedAt: @@ -137,6 +138,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { }, rowCount: table.rowCount, maxRows: table.maxRows, + locks: table.locks, createdAt: table.createdAt instanceof Date ? table.createdAt.toISOString() diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/index.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/index.ts index d458df4c60a..bddfca9fc7b 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/index.ts @@ -2,6 +2,7 @@ export * from './column-config-sidebar' export * from './context-menu' export * from './enrichment-details' export * from './enrichments-sidebar' +export * from './lock-settings-modal' export * from './new-column-dropdown' export * from './row-modal' export * from './run-status-control' @@ -9,4 +10,5 @@ export * from './sidebar-fields' export * from './table-action-bar' export * from './table-filter' export * from './table-grid' +export * from './table-locked-modal' export * from './workflow-sidebar' diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/lock-settings-modal/index.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/lock-settings-modal/index.ts new file mode 100644 index 00000000000..15229b7c62b --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/lock-settings-modal/index.ts @@ -0,0 +1 @@ +export { LockSettingsModal } from './lock-settings-modal' diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/lock-settings-modal/lock-settings-modal.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/lock-settings-modal/lock-settings-modal.tsx new file mode 100644 index 00000000000..b30531933ca --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/lock-settings-modal/lock-settings-modal.tsx @@ -0,0 +1,122 @@ +'use client' + +import { useId, useState } from 'react' +import { + ChipModal, + ChipModalBody, + ChipModalFooter, + ChipModalHeader, + Label, + Switch, + Tooltip, +} from '@sim/emcn' +import { CircleInfo, Lock } from '@sim/emcn/icons' +import type { TableLocks } from '@/lib/table' +import { + describeLocks, + LOCK_FIELDS, +} from '@/app/workspace/[workspaceId]/tables/[tableId]/lock-copy' +import { useUpdateTableLocks } from '@/hooks/queries/tables' + +function locksEqual(a: TableLocks, b: TableLocks): boolean { + return ( + a.schemaLocked === b.schemaLocked && + a.insertLocked === b.insertLocked && + a.updateLocked === b.updateLocked && + a.deleteLocked === b.deleteLocked + ) +} + +interface LockSettingsModalProps { + isOpen: boolean + onClose: () => void + workspaceId: string + tableId: string + locks: TableLocks +} + +/** + * Admin-only panel to toggle a table's four mutation locks. Changes are staged + * locally and applied on Save (one request); the server re-checks admin and + * rejects a `write`-only caller with a 403 surfaced as a toast. Gated at the + * call site on `canAdmin`. + */ +export function LockSettingsModal({ + isOpen, + onClose, + workspaceId, + tableId, + locks, +}: LockSettingsModalProps) { + const idPrefix = useId() + const updateLocks = useUpdateTableLocks(workspaceId) + + // Stage edits locally; reset to the server value each time the modal opens. + const [draft, setDraft] = useState(locks) + const [prevOpen, setPrevOpen] = useState(isOpen) + if (prevOpen !== isOpen) { + setPrevOpen(isOpen) + if (isOpen) setDraft(locks) + } + + const dirty = !locksEqual(draft, locks) + const summary = describeLocks(draft) + + const handleSave = () => { + if (!dirty) { + onClose() + return + } + updateLocks.mutate({ tableId, locks: draft }, { onSuccess: () => onClose() }) + } + + return ( + !open && onClose()} srTitle='Table locks'> + + Table locks + + +

+ {summary.name} — {summary.detail} +

+ {LOCK_FIELDS.map((field) => { + const fieldId = `${idPrefix}-${field.kind}` + return ( +
+
+ + + {/* Not `asChild`: the hint is each lock's only explanation, so + the trigger must be a focusable button for keyboard users. */} + + + + +

{field.hint}

+
+
+
+ + setDraft((prev) => ({ ...prev, [field.key]: checked })) + } + /> +
+ ) + })} +
+ +
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/new-column-dropdown/new-column-dropdown.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/new-column-dropdown/new-column-dropdown.tsx index 2886aad2921..031aa4cbe2b 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/new-column-dropdown/new-column-dropdown.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/new-column-dropdown/new-column-dropdown.tsx @@ -27,6 +27,14 @@ interface NewColumnDropdownProps { onPickType: (type: ColumnDefinition['type']) => void onPickWorkflow: () => void onPickEnrichment: () => void + /** + * When true, the trigger stays visible and clickable but opens nothing — it + * calls {@link onBlocked} instead. Used when the table is schema-locked: + * hiding the control leaves the user guessing, so it stays and explains. + * Paired required so `blocked` can never be set without a handler. + */ + blocked: boolean + onBlocked: () => void } /** @@ -40,27 +48,44 @@ export function NewColumnDropdown({ onPickType, onPickWorkflow, onPickEnrichment, + blocked, + onBlocked, }: NewColumnDropdownProps) { + const triggerButton = + trigger === 'header' ? ( + + ) : ( + + ) + + if (blocked) { + return trigger === 'inline-header' ? ( + {triggerButton} + ) : ( + triggerButton + ) + } + const menu = ( - - {trigger === 'header' ? ( - - ) : ( - - )} - + {triggerButton} <> 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 f10d9e0e720..f220dff3b96 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,13 @@ import { useParams } from 'next/navigation' import { usePostHog } from 'posthog-js/react' import type { RunLimit, RunMode, TableFindMatch } from '@/lib/api/contracts/tables' import { captureEvent } from '@/lib/posthog/client' -import type { ColumnDefinition, Filter, TableRow as TableRowType, WorkflowGroup } from '@/lib/table' +import type { + ColumnDefinition, + Filter, + TableLocks, + 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' @@ -44,6 +50,7 @@ import type { ColumnConfig } from '../column-config-sidebar' import { ContextMenu } from '../context-menu' import { NewColumnDropdown } from '../new-column-dropdown' import { resolveSelectOptions } from '../select-field' +import type { BlockedTableAction } from '../table-locked-modal' import type { WorkflowConfig } from '../workflow-sidebar' import { ExpandedCellPopover } from './cells' import { ADD_COL_WIDTH, COL_WIDTH, SELECTION_TINT_BG } from './constants' @@ -145,6 +152,13 @@ interface TableGridProps { workspaceId?: string tableId?: string embedded?: boolean + /** The table's mutation locks; gates row/schema affordances alongside `canEdit`. */ + locks?: TableLocks + /** + * A mutation the user attempted that a lock forbids. The wrapper explains it + * in a modal — locked controls stay visible and say why, rather than vanishing. + */ + onBlockedAction: (action: BlockedTableAction) => void /** * Pixel width to reserve on the right of the table's scroll content for the * currently-open slideout panel (column config, workflow config, or log @@ -327,6 +341,8 @@ export function TableGrid({ workspaceId: propWorkspaceId, tableId: propTableId, embedded, + locks, + onBlockedAction, sidebarReservedPx, onOpenColumnConfig, onOpenWorkflowConfig, @@ -534,6 +550,27 @@ export function TableGrid({ const userPermissions = useUserPermissionsContext() const canEditRef = useRef(userPermissions.canEdit) canEditRef.current = userPermissions.canEdit + + const canEditCell = userPermissions.canEdit && !locks?.updateLocked + const canDeleteRow = userPermissions.canEdit && !locks?.deleteLocked + const canMutateSchema = userPermissions.canEdit && !locks?.schemaLocked + // Manual grid entry is "add an empty row, then type into its cells" — the + // typing is an update. So a *useful* manual add needs BOTH insert and update + // unlocked; on an append-only table (update locked) it would leave a blank + // row the user can't fill. The control stays visible and explains itself via + // `onBlockedAction`. Full-row inserts still flow through CSV import / API / + // blocks / Mothership, which the insert lock alone governs server-side. + const canManualAddRow = userPermissions.canEdit && !locks?.insertLocked && !locks?.updateLocked + const canEditCellRef = useRef(canEditCell) + canEditCellRef.current = canEditCell + const canManualAddRowRef = useRef(canManualAddRow) + canManualAddRowRef.current = canManualAddRow + // Read by the closure-free double-click handler to tell "locked" apart from + // "no write permission" — only the former gets the explanation modal. + const updateLockedRef = useRef(locks?.updateLocked) + updateLockedRef.current = locks?.updateLocked + const onBlockedActionRef = useRef(onBlockedAction) + onBlockedActionRef.current = onBlockedAction const { dismiss: dismissToast } = useToast() const dismissToastRef = useRef(dismissToast) dismissToastRef.current = dismissToast @@ -654,6 +691,7 @@ export function TableGrid({ const { pushUndo, undo, redo } = useTableUndo({ workspaceId, tableId, + getLocks: () => locks, onColumnOrderChange: handleColumnOrderChange, onColumnRename: handleColumnRename, onColumnWidthsChange: handleColumnWidthsChange, @@ -1197,6 +1235,16 @@ export function TableGrid({ ) }, []) + // Stable identity so 's React.memo still bails out; lock state + // is read from refs instead of being closed over. + const handleAddRowClick = useCallback(() => { + if (!canManualAddRowRef.current) { + onBlockedActionRef.current('add-row') + return + } + void handleAppendRow() + }, [handleAppendRow]) + const handleRowContextMenu = useCallback( (e: React.MouseEvent, row: TableRowType) => { setEditingCell(null) @@ -2027,7 +2075,7 @@ export function TableGrid({ (rowId: string, columnName: string, options?: { toggleBoolean?: boolean }) => { const column = columnsRef.current.find((c) => c.key === columnName) if (column?.type === 'boolean') { - if (!options?.toggleBoolean || !canEditRef.current) return + if (!options?.toggleBoolean || !canEditCellRef.current) return const row = rowsRef.current.find((r) => r.id === rowId) if (row) { toggleBooleanCell(rowId, columnName, row.data[columnName]) @@ -2048,11 +2096,19 @@ export function TableGrid({ const column = columnsRef.current.find((c) => c.key === columnKey) if (column?.type === 'boolean') return + // Double-click means "edit this cell". On an update-locked table, say so + // rather than opening the expanded viewer — which looks like an editor + // that silently refuses to save. + if (updateLockedRef.current) { + onBlockedActionRef.current('edit-cell') + return + } + setSelectionFocus(null) setIsColumnSelection(false) // Date/number: use inline editor (calendar picker / numeric input). - if ((column?.type === 'date' || column?.type === 'number') && canEditRef.current) { + if ((column?.type === 'date' || column?.type === 'number') && canEditCellRef.current) { setEditingCell({ rowId, columnName }) setInitialCharacter(null) return @@ -2192,7 +2248,7 @@ export function TableGrid({ !rowSelectionIsEmpty(rowSelectionRef.current) ) { if (editingCellRef.current) return - if (!canEditRef.current) return + if (!canEditCellRef.current) return e.preventDefault() const rowSel = rowSelectionRef.current void (async () => { @@ -2230,7 +2286,7 @@ export function TableGrid({ const totalRows = currentRows.length if (e.shiftKey && e.key === 'Enter') { - if (!canEditRef.current) return + if (!canManualAddRowRef.current) return const row = currentRows[anchor.rowIndex] if (!row) return e.preventDefault() @@ -2253,7 +2309,7 @@ export function TableGrid({ } if (e.key === 'Enter' || e.key === 'F2') { - if (!canEditRef.current) return + if (!canEditCellRef.current) return e.preventDefault() const col = cols[anchor.colIndex] if (!col) return @@ -2382,7 +2438,7 @@ export function TableGrid({ if ((e.metaKey || e.ctrlKey) && e.key === 'd') { e.preventDefault() - if (!canEditRef.current) return + if (!canEditCellRef.current) return const sel = computeNormalizedSelection(anchor, selectionFocusRef.current) if (!sel || sel.startRow === sel.endRow) return const sourceRow = currentRows[sel.startRow] @@ -2416,7 +2472,7 @@ export function TableGrid({ } if (e.key === 'Delete' || e.key === 'Backspace') { - if (!canEditRef.current) return + if (!canEditCellRef.current) return e.preventDefault() const sel = computeNormalizedSelection(anchor, selectionFocusRef.current) if (!sel) return @@ -2477,7 +2533,7 @@ export function TableGrid({ } if (e.key.length === 1 && !e.metaKey && !e.ctrlKey && !e.altKey) { - if (!canEditRef.current) return + if (!canEditCellRef.current) return const col = cols[anchor.colIndex] // Workflow-output cells are editable: the user can override the // workflow's value if they want. Booleans toggle on space/click — @@ -2680,7 +2736,7 @@ export function TableGrid({ const tag = (e.target as HTMLElement).tagName if (tag === 'INPUT' || tag === 'TEXTAREA') return if (editingCellRef.current) return - if (!canEditRef.current) return + if (!canEditCellRef.current) return const rowSel = rowSelectionRef.current const cols = columnsRef.current @@ -2767,7 +2823,10 @@ export function TableGrid({ const handlePaste = (e: ClipboardEvent) => { const tag = (e.target as HTMLElement).tagName if (tag === 'INPUT' || tag === 'TEXTAREA') return - if (!canEditRef.current) return + // Paste overwrites the selected cells (an edit). Under an update lock we + // block it wholesale — the append-into-new-rows variant is a rarer path + // the user can still reach via the Add row button. + if (!canEditCellRef.current) return const currentAnchor = selectionAnchorRef.current if (!currentAnchor || editingCellRef.current) return @@ -3740,17 +3799,13 @@ export function TableGrid({ onRunColumn={userPermissions.canEdit ? handleRunColumn : undefined} hasActiveFilter={Boolean(effectiveFilter)} selectedRowIds={selectedRowIds} - onInsertLeft={ - userPermissions.canEdit ? handleInsertColumnLeft : undefined - } + onInsertLeft={canMutateSchema ? handleInsertColumnLeft : undefined} onInsertRight={ - userPermissions.canEdit ? handleInsertColumnRight : undefined - } - onDeleteColumn={ - userPermissions.canEdit ? handleDeleteColumn : undefined + canMutateSchema ? handleInsertColumnRight : undefined } + onDeleteColumn={canMutateSchema ? handleDeleteColumn : undefined} onDeleteGroup={ - userPermissions.canEdit ? handleDeleteWorkflowGroup : undefined + canMutateSchema ? handleDeleteWorkflowGroup : undefined } onViewWorkflow={ workflowGroupById.get(g.groupId)?.type === 'enrichment' @@ -3813,7 +3868,9 @@ export function TableGrid({ key={column.key} column={column} colIndex={idx} - readOnly={!userPermissions.canEdit} + // Schema-locked disables the header menu (add/rename/delete + // column, drag-reorder); pin stays via `onPinToggle` below. + readOnly={!canMutateSchema} isRenaming={columnRename.editingId === column.key} isColumnSelected={ isColumnSelection && @@ -3855,6 +3912,8 @@ export function TableGrid({ onBlockedAction('add-column')} onPickType={handleAddColumnOfType} onPickWorkflow={handleAddWorkflowColumn} onPickEnrichment={onOpenEnrichments} @@ -3985,7 +4044,9 @@ export function TableGrid({ )} {!isLoadingTable && !isLoadingRows && userPermissions.canEdit && ( - + onBlockedAction('add-row')} + /> )} @@ -4021,9 +4082,9 @@ export function TableGrid({ runningInSelectionCount={runningInContextSelection} hasWorkflowColumns={hasWorkflowColumns} workflowCellScoped={Boolean(contextMenuGroupId)} - disableEdit={!userPermissions.canEdit} - disableInsert={!userPermissions.canEdit} - disableDelete={!userPermissions.canEdit} + disableEdit={!canEditCell} + disableInsert={!canManualAddRow} + disableDelete={!canDeleteRow} /> 0 + ? `An admin has locked ${nouns.join(', ')} on this table.` + : 'Nothing is locked on this table.', + } + } + } +} + +interface TableLockedModalProps { + /** The attempted action, or `null` when closed. */ + action: BlockedTableAction | null + locks: TableLocks + canAdmin: boolean + onClose: () => void + /** Opens the lock settings panel; only offered to admins. */ + onOpenLockSettings: () => void +} + +/** + * Explains why a table mutation is unavailable. Shown instead of silently + * disabling the control, so the user learns the table is locked rather than + * assuming the UI is broken — and admins get a direct route to the settings. + */ +export function TableLockedModal({ + action, + locks, + canAdmin, + onClose, + onOpenLockSettings, +}: TableLockedModalProps) { + if (!action) return null + const { title, text } = describe(action, locks) + + // Non-admins have nothing to act on, so this is a notice, not a decision — a + // confirm modal would render two buttons that both just dismiss. + if (!canAdmin) { + return ( + !next && onClose()} srTitle={title}> + + {title} + + +

{text}

+
+ +
+ ) + } + + return ( + !next && onClose()} + title={title} + icon={Lock} + text={text} + dismissLabel='Close' + confirm={{ + label: 'Lock settings', + // Navigational, not destructive — the confirm slot defaults to red. + variant: 'primary', + onClick: () => { + onClose() + onOpenLockSettings() + }, + }} + /> + ) +} 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 65416e113d6..67f8a2b54c6 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 @@ -379,6 +379,15 @@ export function useTableEventStream({ else if (entry.event?.kind === 'dispatch') applyDispatch(entry.event) else if (entry.event?.kind === 'job') applyJob(entry.event) else if (entry.event?.kind === 'usageLimitReached') applyUsageLimit(entry.event) + else if (entry.event?.kind === 'definition') { + // A lock/schema change on the definition — re-read it so every open + // viewer's gating updates. `exact` avoids refetching every rows page + // (rowsRoot nests under detail); no row data changed. + void queryClient.invalidateQueries({ + queryKey: tableKeys.detail(tableId), + exact: true, + }) + } } catch (err) { logger.warn('Failed to parse table event', { tableId, err }) } diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/lock-copy.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/lock-copy.ts new file mode 100644 index 00000000000..fe3cd62435e --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/lock-copy.ts @@ -0,0 +1,67 @@ +/** + * Single source of truth for lock vocabulary shared by the lock settings modal, + * the locked-action modal, and the table header chip. Kept out of + * `lib/table/mutation-locks.ts` — that module is server-tainted (importing it + * from a client component pulls `next/headers` into the browser bundle). + */ + +import type { TableLockKind, TableLocks } from '@/lib/table/types' + +export interface LockField { + /** The `TableLocks` flag this row toggles. */ + key: keyof TableLocks + kind: TableLockKind + /** The action being locked, phrased to read after "Lock " and inside a list. */ + noun: string + hint: string +} + +export const LOCK_FIELDS: LockField[] = [ + { + key: 'insertLocked', + kind: 'insert', + noun: 'adding rows', + hint: 'On: no new rows can be added — by anyone, including CSV import, the API, workflow blocks, and Sim.', + }, + { + key: 'updateLocked', + kind: 'update', + noun: 'editing rows', + hint: 'On: existing cell values cannot be changed. Workflow and enrichment columns still populate.', + }, + { + key: 'deleteLocked', + kind: 'delete', + noun: 'deleting rows', + hint: 'On: rows cannot be deleted, and the table cannot be archived.', + }, + { + key: 'schemaLocked', + kind: 'schema', + noun: 'changing columns', + hint: 'On: columns cannot be added, renamed, retyped, or removed.', + }, +] + +/** The locked verbs' nouns, in display order. Empty when nothing is locked. */ +export function lockedNouns(locks: TableLocks): string[] { + return LOCK_FIELDS.filter((f) => locks[f.key]).map((f) => f.noun) +} + +/** + * Plain-language summary of a lock set — the named mode when the combination + * matches one, otherwise a list of what is locked. + */ +export function describeLocks(locks: TableLocks): { name: string; detail: string } { + const locked = lockedNouns(locks) + if (locked.length === 0) { + return { name: 'Unlocked', detail: 'anyone with edit access can change this table.' } + } + if (locked.length === LOCK_FIELDS.length) { + return { name: 'Read-only', detail: 'no one can change this table’s rows or columns.' } + } + if (!locks.insertLocked && locks.updateLocked && locks.deleteLocked) { + return { name: 'Append-only', detail: 'rows can be added, but not edited or deleted.' } + } + return { name: 'Locked', detail: `${locked.join(', ')} locked.` } +} diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx index 3094439a10e..a3ee41d537b 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx @@ -2,7 +2,7 @@ import { useCallback, useMemo, useReducer, useRef, useState } from 'react' import { Chip, ChipConfirmModal, toast } from '@sim/emcn' -import { Download, Pencil, Table as TableIcon, Trash, Upload } from '@sim/emcn/icons' +import { Download, Lock, Pencil, Table as TableIcon, Trash, Upload } from '@sim/emcn/icons' import { createLogger } from '@sim/logger' import { useParams, useRouter } from 'next/navigation' import { useQueryStates } from 'nuqs' @@ -43,10 +43,12 @@ import { useSettingsNavigation } from '@/hooks/use-settings-navigation' import { useLogDetailsUIStore } from '@/stores/logs/store' import type { DeletedRowSnapshot } from '@/stores/table/types' import { + type BlockedTableAction, type ColumnConfig, ColumnConfigSidebar, EnrichmentDetails, EnrichmentsSidebar, + LockSettingsModal, NewColumnDropdown, RowModal, RunStatusControl, @@ -54,12 +56,14 @@ import { TableActionBar, TableFilter, TableGrid, + TableLockedModal, type WorkflowConfig, WorkflowSidebar, } from './components' import { COLUMN_SIDEBAR_WIDTH } from './components/table-grid/constants' import { COLUMN_TYPE_ICONS } from './components/table-grid/headers' import { useTable, useTableEventStream } from './hooks' +import { describeLocks, lockedNouns } from './lock-copy' import { DEFAULT_TABLE_DETAIL_SORT_DIRECTION, tableDetailParsers, @@ -155,6 +159,8 @@ export function Table({ const [slideout, dispatch] = useReducer(slideoutReducer, { kind: 'none' }) const [showDeleteTableConfirm, setShowDeleteTableConfirm] = useState(false) + const [showLockSettings, setShowLockSettings] = useState(false) + const [blockedAction, setBlockedAction] = useState(null) const [isImportCsvOpen, setIsImportCsvOpen] = useState(false) const [editingRow, setEditingRow] = useState(null) const [deletingRows, setDeletingRows] = useState([]) @@ -550,10 +556,20 @@ export function Table({ icon: Pencil, onClick: handleStartTableRename, }, + ...(userPermissions.canAdmin + ? [ + { + label: 'Lock settings', + icon: Lock, + onClick: () => setShowLockSettings(true), + }, + ] + : []), { label: 'Delete', icon: Trash, onClick: onRequestDeleteTable, + disabled: userPermissions.canEdit !== true || tableData.locks.deleteLocked, }, ], } @@ -561,6 +577,8 @@ export function Table({ ], [ handleNavigateBack, + userPermissions.canAdmin, + userPermissions.canEdit, tableData, tableHeaderRename.editingId, tableHeaderRename.editValue, @@ -572,31 +590,53 @@ export function Table({ ] ) - const headerActions = useMemo( - () => - tableData + const headerActions = useMemo(() => { + if (!tableData) return undefined + const anyLocked = lockedNouns(tableData.locks).length > 0 + // Name the mode when locked so the state is legible on open; admins always + // get the entry point, even on an unlocked table. + const lockLabel = anyLocked ? describeLocks(tableData.locks).name : 'Lock settings' + return [ + ...(anyLocked || userPermissions.canAdmin ? [ { - label: 'Import CSV', - icon: Upload, - onClick: onRequestImportCsv, - disabled: userPermissions.canEdit !== true, - }, - { - label: 'Export CSV', - icon: Download, - onClick: () => void handleExportCsv(), - disabled: tableData.rowCount === 0, + label: lockLabel, + icon: Lock, + onClick: () => + userPermissions.canAdmin ? setShowLockSettings(true) : setBlockedAction('status'), }, ] - : undefined, - [tableData, userPermissions.canEdit, handleExportCsv, onRequestImportCsv] - ) + : []), + { + label: 'Import CSV', + icon: Upload, + onClick: onRequestImportCsv, + disabled: userPermissions.canEdit !== true, + }, + { + label: 'Export CSV', + icon: Download, + onClick: () => void handleExportCsv(), + disabled: tableData.rowCount === 0, + }, + ] + }, [ + tableData, + userPermissions.canEdit, + userPermissions.canAdmin, + handleExportCsv, + onRequestImportCsv, + ]) + // Adding a column is a schema change. The trigger stays visible when the + // table is schema-locked and explains itself instead of disappearing. + const canMutateSchema = userPermissions.canEdit && !tableData?.locks.schemaLocked const createTrigger = userPermissions.canEdit ? ( setBlockedAction('add-column')} onPickType={handleAddColumnOfType} onPickWorkflow={handleAddWorkflowColumn} onPickEnrichment={onOpenEnrichments} @@ -719,6 +759,8 @@ export function Table({ workspaceId={workspaceId} tableId={tableId} embedded={embedded} + locks={tableData?.locks} + onBlockedAction={setBlockedAction} sidebarReservedPx={sidebarReservedPx} onOpenColumnConfig={onOpenColumnConfig} onOpenWorkflowConfig={onOpenWorkflowConfig} @@ -973,6 +1015,24 @@ export function Table({ }} /> )} + {tableData && userPermissions.canAdmin && ( + setShowLockSettings(false)} + workspaceId={workspaceId} + tableId={tableData.id} + locks={tableData.locks} + /> + )} + {tableData && ( + setBlockedAction(null)} + onOpenLockSettings={() => setShowLockSettings(true)} + /> + )} ) } diff --git a/apps/sim/ee/workspace-forking/lib/copy/copy-resources.ts b/apps/sim/ee/workspace-forking/lib/copy/copy-resources.ts index 63c32d56549..2b4aea14ae9 100644 --- a/apps/sim/ee/workspace-forking/lib/copy/copy-resources.ts +++ b/apps/sim/ee/workspace-forking/lib/copy/copy-resources.ts @@ -523,6 +523,14 @@ export async function copyForkResourceContainers( // Start at 0 - the post-commit content copy raises it to the rows actually // copied, so a failed/partial copy never advertises the source's count. rowCount: 0, + // Locks are workspace-local governance and never transit a fork edge — + // mirrors `copy-workflows.ts` writing `locked: false`. Inheriting them + // would hand the fork owner a frozen workspace they may lack admin to + // unlock. Reset explicitly since the spread above copies every column. + schemaLocked: false, + insertLocked: false, + updateLocked: false, + deleteLocked: false, archivedAt: null, createdAt: now, updatedAt: now, diff --git a/apps/sim/hooks/queries/tables.test.ts b/apps/sim/hooks/queries/tables.test.ts index e3ed602c1c1..d926aaa291d 100644 --- a/apps/sim/hooks/queries/tables.test.ts +++ b/apps/sim/hooks/queries/tables.test.ts @@ -45,6 +45,8 @@ vi.mock('@/lib/api/client/request', () => ({ vi.mock('@/lib/api/client/errors', () => ({ isValidationError: vi.fn(() => false), + isApiClientError: vi.fn(() => false), + extractValidationIssues: vi.fn(() => []), })) vi.mock('@/lib/api/contracts/tables', () => ({ diff --git a/apps/sim/hooks/queries/tables.ts b/apps/sim/hooks/queries/tables.ts index 8ce978c28ef..84d9dcc13ee 100644 --- a/apps/sim/hooks/queries/tables.ts +++ b/apps/sim/hooks/queries/tables.ts @@ -16,7 +16,11 @@ import { useQueryClient, } from '@tanstack/react-query' import { useRouter } from 'next/navigation' -import { extractValidationIssues, isValidationError } from '@/lib/api/client/errors' +import { + extractValidationIssues, + isApiClientError, + isValidationError, +} from '@/lib/api/client/errors' import { requestJson } from '@/lib/api/client/request' import type { ContractJsonResponse } from '@/lib/api/contracts' import { @@ -61,12 +65,14 @@ import { type TableFindMatch, type TableIdParamsInput, type TableJobSummary, + type TableLocksInput, type TableRowParamsInput, type TableRowsQueryInput, type UpdateTableColumnBodyInput, type UpdateTableRowBodyInput, type UpdateWorkflowGroupBodyInput, updateTableColumnContract, + updateTableContract, updateTableMetadataContract, updateTableRowContract, updateWorkflowGroupContract, @@ -546,6 +552,7 @@ export function useAddTableColumn({ workspaceId, tableId }: RowMutationContext) }) }, onError: (error) => { + if (handleTableLockRejection(error, queryClient, tableId)) return if (isValidationError(error)) return toast.error(error.message, { duration: 5000 }) }, @@ -580,6 +587,52 @@ export function useRenameTable(workspaceId: string) { }) } +/** + * Toggle a table's mutation locks (admin-only; the server enforces the role). + * Optimistically patches the detail cache so the settings switches respond + * instantly, reconciling on settle. Uses `exact` on the detail invalidation so + * the rows pages (nested under detail) aren't needlessly refetched. + */ +export function useUpdateTableLocks(workspaceId: string) { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({ + tableId, + locks, + }: { + tableId: string + locks: Partial + }) => { + return requestJson(updateTableContract, { + params: { tableId }, + body: { workspaceId, locks }, + }) + }, + onMutate: async ({ tableId, locks }) => { + await queryClient.cancelQueries({ queryKey: tableKeys.detail(tableId) }) + const previousDetail = queryClient.getQueryData(tableKeys.detail(tableId)) + if (previousDetail) { + queryClient.setQueryData(tableKeys.detail(tableId), { + ...previousDetail, + locks: { ...previousDetail.locks, ...locks }, + }) + } + return { previousDetail } + }, + onError: (error, { tableId }, context) => { + if (context?.previousDetail) { + queryClient.setQueryData(tableKeys.detail(tableId), context.previousDetail) + } + toast.error(error.message, { duration: 5000 }) + }, + onSettled: (_data, _error, { tableId }) => { + queryClient.invalidateQueries({ queryKey: tableKeys.detail(tableId), exact: true }) + queryClient.invalidateQueries({ queryKey: tableKeys.lists() }) + }, + }) +} + /** * Delete a table from a workspace. */ @@ -593,7 +646,8 @@ export function useDeleteTable(workspaceId: string) { query: { workspaceId }, }) }, - onError: (error) => { + onError: (error, tableId) => { + if (handleTableLockRejection(error, queryClient, tableId)) return if (isValidationError(error)) return toast.error(error.message, { duration: 5000 }) }, @@ -627,6 +681,26 @@ function notifyRowWriteError(error: Error, onUpgrade: () => void): void { toast.error(error.message, { duration: 5000 }) } +/** + * Self-heals a 423 lock rejection: refreshes the (now-known-stale) table + * definition so the grid's gating catches up, refreshes the list, toasts the + * lock reason, and reports whether it handled the error. Call FIRST in a row + * mutation's `onError` — a lock set by another user (or Mothership) since this + * grid loaded is otherwise invisible until a manual refresh. `exact` avoids + * refetching every rows page (rowsRoot nests under detail). + */ +function handleTableLockRejection( + error: unknown, + queryClient: ReturnType, + tableId: string +): boolean { + if (!isApiClientError(error) || error.status !== 423) return false + void queryClient.invalidateQueries({ queryKey: tableKeys.detail(tableId), exact: true }) + void queryClient.invalidateQueries({ queryKey: tableKeys.lists() }) + toast.error(error.message, { duration: 5000 }) + return true +} + export function useCreateTableRow({ workspaceId, tableId }: RowMutationContext) { const queryClient = useQueryClient() const router = useRouter() @@ -674,8 +748,10 @@ export function useCreateTableRow({ workspaceId, tableId }: RowMutationContext) predicate: (query) => !isDefaultOrderRowsQuery(query.queryKey), }) }, - onError: (error) => - notifyRowWriteError(error, () => router.push(buildUpgradeHref(workspaceId, 'tables'))), + onError: (error) => { + if (handleTableLockRejection(error, queryClient, tableId)) return + notifyRowWriteError(error, () => router.push(buildUpgradeHref(workspaceId, 'tables'))) + }, onSettled: () => { // `reconcileCreatedRow` (onSuccess) is the source of truth for the rows // cache + its `totalCount`; only refresh the count surfaces here so a late @@ -853,8 +929,10 @@ export function useBatchCreateTableRows({ workspaceId, tableId }: RowMutationCon }, }) }, - onError: (error) => - notifyRowWriteError(error, () => router.push(buildUpgradeHref(workspaceId, 'tables'))), + onError: (error) => { + if (handleTableLockRejection(error, queryClient, tableId)) return + notifyRowWriteError(error, () => router.push(buildUpgradeHref(workspaceId, 'tables'))) + }, onSettled: () => { invalidateRowCount(queryClient, tableId) }, @@ -938,6 +1016,7 @@ export function useUpdateTableRow({ workspaceId, tableId }: RowMutationContext) if (context?.didBumpRunState) { queryClient.setQueryData(tableKeys.activeDispatches(tableId), context.runStateSnapshot) } + if (handleTableLockRejection(error, queryClient, tableId)) return if (isValidationError(error)) return toast.error(error.message, { duration: 5000 }) }, @@ -1011,6 +1090,7 @@ export function useBatchUpdateTableRows({ workspaceId, tableId }: RowMutationCon if (context?.didBumpRunState) { queryClient.setQueryData(tableKeys.activeDispatches(tableId), context.runStateSnapshot) } + if (handleTableLockRejection(error, queryClient, tableId)) return if (isValidationError(error)) return toast.error(error.message, { duration: 5000 }) }, @@ -1031,6 +1111,7 @@ export function useDeleteTableRow({ workspaceId, tableId }: RowMutationContext) }) }, onError: (error) => { + if (handleTableLockRejection(error, queryClient, tableId)) return if (isValidationError(error)) return toast.error(error.message, { duration: 5000 }) }, @@ -1079,6 +1160,7 @@ export function useDeleteTableRows({ workspaceId, tableId }: RowMutationContext) return { deletedRowIds } }, onError: (error) => { + if (handleTableLockRejection(error, queryClient, tableId)) return if (isValidationError(error)) return toast.error(error.message, { duration: 5000 }) }, @@ -1174,6 +1256,7 @@ export function useDeleteTableRowsAsync({ workspaceId, tableId }: RowMutationCon if (context?.previousDetail) { queryClient.setQueryData(tableKeys.detail(tableId), context.previousDetail) } + if (handleTableLockRejection(error, queryClient, tableId)) return if (isValidationError(error)) return toast.error(error.message, { duration: 5000 }) }, @@ -1242,6 +1325,7 @@ export function useUpdateColumn({ workspaceId, tableId }: RowMutationContext) { if (context?.previousDetail) { queryClient.setQueryData(tableKeys.detail(tableId), context.previousDetail) } + if (handleTableLockRejection(error, queryClient, tableId)) return if (isValidationError(error)) return toast.error(error.message, { duration: 5000 }) }, @@ -1446,7 +1530,8 @@ export function useRestoreTable() { params: { tableId }, }) }, - onError: (error) => { + onError: (error, tableId) => { + if (handleTableLockRejection(error, queryClient, tableId)) return if (isValidationError(error)) return toast.error(error.message, { duration: 5000 }) }, @@ -1794,6 +1879,7 @@ export function useExportTableAsync({ workspaceId, tableId }: RowMutationContext void queryClient.invalidateQueries({ queryKey: tableKeys.exportJobs(workspaceId) }) }, onError: (error) => { + if (handleTableLockRejection(error, queryClient, tableId)) return if (isValidationError(error)) return toast.error(error.message, { duration: 5000 }) }, @@ -1905,6 +1991,7 @@ export function useDeleteColumn({ workspaceId, tableId }: RowMutationContext) { queryClient.setQueryData(key, data) } } + if (handleTableLockRejection(error, queryClient, tableId)) return if (isValidationError(error)) return toast.error(error.message, { duration: 5000 }) }, @@ -2136,12 +2223,15 @@ export function useRunColumn({ workspaceId, tableId }: RowMutationContext) { const bumped = await bumpRunState(queryClient, tableId, stampedByRow) return { snapshots, runStateSnapshot: bumped?.snapshot, didBumpRunState: bumped !== null } }, - onError: (_err, _variables, context) => { + onError: (error, _variables, context) => { if (context?.snapshots) restoreCachedWorkflowCells(queryClient, context.snapshots) // Roll back the optimistic counter bump (snapshot may be undefined). if (context?.didBumpRunState) { queryClient.setQueryData(tableKeys.activeDispatches(tableId), context.runStateSnapshot) } + if (handleTableLockRejection(error, queryClient, tableId)) return + if (isValidationError(error)) return + toast.error(error.message, { duration: 5000 }) }, onSuccess: (data, { groupIds, runMode = 'all', rowIds, limit }, context) => { // Seed the dispatch into the overlay (drives resolveCellExec for @@ -2202,6 +2292,7 @@ export function useAddWorkflowGroup({ workspaceId, tableId }: RowMutationContext }) }, onError: (error) => { + if (handleTableLockRejection(error, queryClient, tableId)) return if (isValidationError(error)) return toast.error(error.message, { duration: 5000 }) }, @@ -2235,6 +2326,7 @@ export function useUpdateWorkflowGroup({ workspaceId, tableId }: RowMutationCont }) }, onError: (error) => { + if (handleTableLockRejection(error, queryClient, tableId)) return if (isValidationError(error)) return toast.error(error.message, { duration: 5000 }) }, @@ -2259,6 +2351,7 @@ export function useDeleteWorkflowGroup({ workspaceId, tableId }: RowMutationCont }) }, onError: (error) => { + if (handleTableLockRejection(error, queryClient, tableId)) return if (isValidationError(error)) return toast.error(error.message, { duration: 5000 }) }, diff --git a/apps/sim/hooks/use-table-undo.test.ts b/apps/sim/hooks/use-table-undo.test.ts index 3c19882c6e3..7bb61cb90e7 100644 --- a/apps/sim/hooks/use-table-undo.test.ts +++ b/apps/sim/hooks/use-table-undo.test.ts @@ -13,6 +13,10 @@ vi.mock('react', () => ({ const mockMutate = vi.fn() const mockMutateAsync = vi.fn() +vi.mock('@sim/emcn', () => ({ + toast: Object.assign(vi.fn(), { error: vi.fn(), success: vi.fn() }), +})) + vi.mock('@/hooks/queries/tables', () => ({ useUpdateTableRow: vi.fn(() => ({ mutate: mockMutate })), useCreateTableRow: vi.fn(() => ({ mutate: mockMutate })), @@ -49,7 +53,10 @@ const storeState = { } vi.mock('@/stores/table/store', () => ({ - useTableUndoStore: vi.fn((selector: (s: typeof storeState) => unknown) => selector(storeState)), + useTableUndoStore: Object.assign( + vi.fn((selector: (s: typeof storeState) => unknown) => selector(storeState)), + { getState: () => storeState } + ), runWithoutRecording: (fn: () => unknown) => Promise.resolve(fn()), })) diff --git a/apps/sim/hooks/use-table-undo.ts b/apps/sim/hooks/use-table-undo.ts index a65672c3f78..2063da91b20 100644 --- a/apps/sim/hooks/use-table-undo.ts +++ b/apps/sim/hooks/use-table-undo.ts @@ -1,6 +1,8 @@ import { useCallback, useEffect, useRef } from 'react' +import { toast } from '@sim/emcn' import { createLogger } from '@sim/logger' import { TABLE_LIMITS } from '@/lib/table/constants' +import type { TableLockKind, TableLocks } from '@/lib/table/types' import { useAddTableColumn, useBatchCreateTableRows, @@ -18,6 +20,70 @@ import type { TableUndoAction } from '@/stores/table/types' const logger = createLogger('useTableUndo') +/** + * The mutation verb an undo/redo of `action` would perform in `direction`. Undo + * inverts row create/delete (undoing a create deletes; undoing a delete + * re-inserts), so the verb depends on direction. Column ops are always `schema`; + * rename/reorder touch no locked verb (`null`). + */ +function undoActionVerb(action: TableUndoAction, direction: 'undo' | 'redo'): TableLockKind | null { + switch (action.type) { + case 'update-cell': + case 'update-cells': + case 'clear-cells': + return 'update' + case 'create-row': + case 'create-rows': + return direction === 'undo' ? 'delete' : 'insert' + case 'delete-rows': + return direction === 'undo' ? 'insert' : 'delete' + case 'create-column': + case 'delete-column': + case 'rename-column': + case 'update-column-type': + case 'toggle-column-constraint': + return 'schema' + default: + return null + } +} + +/** The lock (if any) that would block replaying `action` in `direction`. */ +function undoActionBlockedBy( + action: TableUndoAction, + direction: 'undo' | 'redo', + locks: TableLocks | undefined +): TableLockKind | null { + if (!locks) return null + switch (undoActionVerb(action, direction)) { + case 'insert': + return locks.insertLocked ? 'insert' : null + case 'update': + return locks.updateLocked ? 'update' : null + case 'delete': + return locks.deleteLocked ? 'delete' : null + case 'schema': + return locks.schemaLocked ? 'schema' : null + default: + return null + } +} + +const BLOCKED_UNDO_MESSAGE: Record = { + insert: 'This table is insert-locked — that step cannot be undone.', + update: 'This table is update-locked — that step cannot be undone.', + delete: 'This table is delete-locked — that step cannot be undone.', + schema: 'This table is schema-locked — that step cannot be undone.', +} + +/** + * Top of a stack without popping, so a lock-blocked step can be left in place + * rather than popped-and-lost — see `undo`/`redo`. + */ +function peekAction(tableId: string, stack: 'undo' | 'redo'): TableUndoAction | undefined { + return useTableUndoStore.getState().stacks[tableId]?.[stack].at(-1)?.action +} + export function extractCreatedRowId(response: Record): string | undefined { const data = response?.data as Record | undefined const row = data?.row as Record | undefined @@ -27,6 +93,8 @@ export function extractCreatedRowId(response: Record): string | interface UseTableUndoProps { workspaceId: string tableId: string + /** Current table locks, so undo/redo skips a step whose verb is locked. */ + getLocks?: () => TableLocks | undefined onColumnOrderChange?: (order: string[]) => void onColumnRename?: (oldName: string, newName: string) => void onColumnWidthsChange?: (widths: Record) => void @@ -38,6 +106,7 @@ interface UseTableUndoProps { export function useTableUndo({ workspaceId, tableId, + getLocks, onColumnOrderChange, onColumnRename, onColumnWidthsChange, @@ -77,6 +146,8 @@ export function useTableUndo({ getPinnedColumnsRef.current = getPinnedColumns const getColumnWidthsRef = useRef(getColumnWidths) getColumnWidthsRef.current = getColumnWidths + const getLocksRef = useRef(getLocks) + getLocksRef.current = getLocks useEffect(() => { return () => clear(tableId) @@ -435,12 +506,30 @@ export function useTableUndo({ ) const undo = useCallback(() => { + // Check the lock BEFORE popping — a blocked step must stay on the stack, not + // be silently discarded (the mutation would 423 and the entry would be lost). + const next = peekAction(tableId, 'undo') + if (next) { + const blocked = undoActionBlockedBy(next, 'undo', getLocksRef.current?.()) + if (blocked) { + toast.error(BLOCKED_UNDO_MESSAGE[blocked], { duration: 5000 }) + return + } + } const entry = popUndo(tableId) if (!entry) return void runWithoutRecording(() => executeAction(entry.action, 'undo')) }, [popUndo, tableId, executeAction]) const redo = useCallback(() => { + const next = peekAction(tableId, 'redo') + if (next) { + const blocked = undoActionBlockedBy(next, 'redo', getLocksRef.current?.()) + if (blocked) { + toast.error(BLOCKED_UNDO_MESSAGE[blocked], { duration: 5000 }) + return + } + } const entry = popRedo(tableId) if (!entry) return void runWithoutRecording(() => executeAction(entry.action, 'redo')) diff --git a/apps/sim/lib/api/contracts/tables.ts b/apps/sim/lib/api/contracts/tables.ts index 73aa2a00fb1..c1107e3591d 100644 --- a/apps/sim/lib/api/contracts/tables.ts +++ b/apps/sim/lib/api/contracts/tables.ts @@ -9,6 +9,7 @@ import type { RowData, Sort, TableDefinition, + TableLocks, TableMetadata, TableRow, TableRowsCursor, @@ -171,6 +172,35 @@ export const renameTableBodySchema = z.object({ name: tableNameSchema, }) +/** Full per-table lock set (all four flags). */ +export const tableLocksSchema = z.object({ + schemaLocked: z.boolean(), + insertLocked: z.boolean(), + updateLocked: z.boolean(), + deleteLocked: z.boolean(), +}) satisfies z.ZodType + +/** + * PATCH /api/table/[tableId] body. Both fields are optional but at least one + * must be present. `name` is a `write`-level rename; `locks` is an + * admin-only governance change (a partial — only the toggled flags are sent). + */ +export const updateTableBodySchema = z + .object({ + workspaceId: z.string().min(1, 'Workspace ID is required'), + name: tableNameSchema.optional(), + locks: tableLocksSchema.partial().optional(), + }) + .superRefine((body, ctx) => { + if (body.name === undefined && body.locks === undefined) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Provide a new name or lock changes', + path: ['name'], + }) + } + }) + export const createTableColumnBodySchema = z.object({ workspaceId: z.string().min(1, 'Workspace ID is required'), column: z @@ -515,6 +545,26 @@ export const renameTableContract = defineRouteContract({ }, }) +/** + * Superset of {@link renameTableContract} on the same PATCH endpoint: accepts a + * `name` rename and/or a `locks` change. The route applies its own permission + * split — `name` needs workspace `write`, `locks` needs `admin`. + */ +export const updateTableContract = defineRouteContract({ + method: 'PATCH', + path: '/api/table/[tableId]', + params: tableIdParamsSchema, + body: updateTableBodySchema, + response: { + mode: 'json', + schema: successResponseSchema(z.object({ table: tableDefinitionSchema })), + }, +}) + +export type TableLocksInput = z.input +export type UpdateTableBody = z.input +export type UpdateTableResponse = ContractJsonResponse + export const deleteTableContract = defineRouteContract({ method: 'DELETE', path: '/api/table/[tableId]', diff --git a/apps/sim/lib/copilot/request/tools/tables.test.ts b/apps/sim/lib/copilot/request/tools/tables.test.ts index 147603c2417..c9ab80ae41b 100644 --- a/apps/sim/lib/copilot/request/tools/tables.test.ts +++ b/apps/sim/lib/copilot/request/tools/tables.test.ts @@ -49,6 +49,7 @@ function buildTable(overrides: Partial = {}): TableDefinition { maxRows: 100, workspaceId: 'workspace-1', createdBy: 'user-1', + locks: { schemaLocked: false, insertLocked: false, updateLocked: false, deleteLocked: false }, archivedAt: null, createdAt: new Date('2024-01-01'), updatedAt: new Date('2024-01-01'), diff --git a/apps/sim/lib/copilot/tools/server/table/user-table.ts b/apps/sim/lib/copilot/tools/server/table/user-table.ts index aac6adf93e2..bd1b95607f8 100644 --- a/apps/sim/lib/copilot/tools/server/table/user-table.ts +++ b/apps/sim/lib/copilot/tools/server/table/user-table.ts @@ -47,6 +47,7 @@ import { import { markTableDeleteFailed, runTableDelete } from '@/lib/table/delete-runner' import { runTableImport, type TableImportPayload } from '@/lib/table/import-runner' import { markTableJobRunning, releaseJobClaim } from '@/lib/table/jobs/service' +import { assertRowDelete, assertRowUpdate, patchColumnIds } from '@/lib/table/mutation-locks' import { batchInsertRows, batchUpdateRows, @@ -773,7 +774,11 @@ export const userTableServerTool: BaseServerTool const requestId = generateId().slice(0, 8) assertNotAborted() - await deleteRow(args.tableId, args.rowId, workspaceId, requestId) + const deleteRowTable = await getTableById(args.tableId) + if (!deleteRowTable) { + return { success: false, message: `Table ${args.tableId} not found` } + } + await deleteRow(deleteRowTable, args.rowId, requestId) return { success: true, @@ -841,6 +846,9 @@ export const userTableServerTool: BaseServerTool affectedCount: target, maxRows: args.limit, } + // Gate the update lock at enqueue — the background worker is a + // trusted continuation and does not re-check. + assertRowUpdate(table, patchColumnIds(idData)) assertNotAborted() const claimed = await markTableJobRunning(table.id, jobId, 'update', payload) if (!claimed) { @@ -934,6 +942,8 @@ export const userTableServerTool: BaseServerTool const payload: TableDeleteJobPayload = bounded ? { filter: idFilter, cutoff: cutoff.toISOString(), maxRows: args.limit } : { filter: idFilter, cutoff: cutoff.toISOString(), doomedCount } + // Gate the delete lock at enqueue — the worker is a trusted continuation. + assertRowDelete(table) assertNotAborted() const claimed = await markTableJobRunning(table.id, jobId, 'delete', payload) if (!claimed) { @@ -1090,7 +1100,12 @@ export const userTableServerTool: BaseServerTool const requestId = generateId().slice(0, 8) assertNotAborted() + const batchDeleteTable = await getTableById(args.tableId) + if (!batchDeleteTable) { + return { success: false, message: `Table ${args.tableId} not found` } + } const result = await deleteRowsByIds( + batchDeleteTable, { tableId: args.tableId, rowIds, workspaceId }, requestId ) diff --git a/apps/sim/lib/core/config/env.ts b/apps/sim/lib/core/config/env.ts index 80c9081dcc2..76b87384171 100644 --- a/apps/sim/lib/core/config/env.ts +++ b/apps/sim/lib/core/config/env.ts @@ -473,6 +473,7 @@ export const env = createEnv({ DATA_DRAINS_ENABLED: z.boolean().optional(), // Enable data drains on self-hosted (bypasses hosted requirements) FORKING_ENABLED: z.boolean().optional(), // Enable workspace forking on self-hosted (bypasses hosted requirements) DEPLOY_AS_BLOCK: z.boolean().optional(), // Enable deploy-as-block (publish a workflow as a reusable org-wide custom block) + TABLE_LOCKS: z.boolean().optional(), // Enable per-table mutation locks (schema/insert/update/delete toggles) // Organizations - for self-hosted deployments ORGANIZATIONS_ENABLED: z.boolean().optional(), // Enable organizations on self-hosted (bypasses plan requirements) diff --git a/apps/sim/lib/core/config/feature-flags.ts b/apps/sim/lib/core/config/feature-flags.ts index 558707fa587..69c32290589 100644 --- a/apps/sim/lib/core/config/feature-flags.ts +++ b/apps/sim/lib/core/config/feature-flags.ts @@ -112,6 +112,15 @@ const FEATURE_FLAGS = { 'custom-block publish/list routes. Off-AppConfig falls back to DEPLOY_AS_BLOCK.', fallback: 'DEPLOY_AS_BLOCK', }, + 'table-locks': { + description: + 'Per-table mutation locks (schema/insert/update/delete) an admin toggles to make a table ' + + 'append-only or read-only. Gates the ability to SET locks (the settings UI + the PATCH ' + + 'locks branch); enforcement of any lock already stored always runs. Since new tables are ' + + 'created unlocked and forks reset locks, an off flag means no table can become locked, so ' + + 'the woven-in asserts stay no-ops. Off-AppConfig falls back to TABLE_LOCKS.', + fallback: 'TABLE_LOCKS', + }, } satisfies Record /** 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 bef0eb0f681..5ff29ce21fd 100644 --- a/apps/sim/lib/table/__tests__/find-row-matches.test.ts +++ b/apps/sim/lib/table/__tests__/find-row-matches.test.ts @@ -54,6 +54,7 @@ const TABLE: TableDefinition = { maxRows: 1000, workspaceId: 'ws-1', createdBy: 'user-1', + locks: { schemaLocked: false, insertLocked: false, updateLocked: false, deleteLocked: false }, archivedAt: null, createdAt: new Date('2024-01-01'), updatedAt: new Date('2024-01-01'), diff --git a/apps/sim/lib/table/__tests__/lock-order.test.ts b/apps/sim/lib/table/__tests__/lock-order.test.ts index 580584d438c..d2c988f8f8a 100644 --- a/apps/sim/lib/table/__tests__/lock-order.test.ts +++ b/apps/sim/lib/table/__tests__/lock-order.test.ts @@ -39,6 +39,7 @@ const TABLE: TableDefinition = { maxRows: 1000, workspaceId: 'ws-1', createdBy: 'user-1', + locks: { schemaLocked: false, insertLocked: false, updateLocked: false, deleteLocked: false }, archivedAt: null, createdAt: new Date('2024-01-01'), updatedAt: new Date('2024-01-01'), diff --git a/apps/sim/lib/table/__tests__/service-filter-threading.test.ts b/apps/sim/lib/table/__tests__/service-filter-threading.test.ts index 8e19a08c84d..582df6cd2c3 100644 --- a/apps/sim/lib/table/__tests__/service-filter-threading.test.ts +++ b/apps/sim/lib/table/__tests__/service-filter-threading.test.ts @@ -59,6 +59,7 @@ const TABLE: TableDefinition = { maxRows: 1000, workspaceId: 'ws-1', createdBy: 'user-1', + locks: { schemaLocked: false, insertLocked: false, updateLocked: false, deleteLocked: false }, archivedAt: null, createdAt: new Date('2024-01-01'), updatedAt: new Date('2024-01-01'), diff --git a/apps/sim/lib/table/__tests__/update-row.test.ts b/apps/sim/lib/table/__tests__/update-row.test.ts index e606333026d..a97d5dd526a 100644 --- a/apps/sim/lib/table/__tests__/update-row.test.ts +++ b/apps/sim/lib/table/__tests__/update-row.test.ts @@ -89,6 +89,7 @@ const TABLE: TableDefinition = { maxRows: 1000, workspaceId: 'ws-1', createdBy: 'user-1', + locks: { schemaLocked: false, insertLocked: false, updateLocked: false, deleteLocked: false }, archivedAt: null, createdAt: new Date('2024-01-01'), updatedAt: new Date('2024-01-01'), diff --git a/apps/sim/lib/table/cell-write.test.ts b/apps/sim/lib/table/cell-write.test.ts index 1e31546f489..42066e1a0cb 100644 --- a/apps/sim/lib/table/cell-write.test.ts +++ b/apps/sim/lib/table/cell-write.test.ts @@ -47,6 +47,7 @@ const TABLE: TableDefinition = { maxRows: 100, workspaceId: 'workspace-1', createdBy: 'user-1', + locks: { schemaLocked: false, insertLocked: false, updateLocked: false, deleteLocked: false }, createdAt: new Date('2026-01-01T00:00:00.000Z'), updatedAt: new Date('2026-01-01T00:00:00.000Z'), } diff --git a/apps/sim/lib/table/columns/service.ts b/apps/sim/lib/table/columns/service.ts index 6aecd5dce09..4226dc935f9 100644 --- a/apps/sim/lib/table/columns/service.ts +++ b/apps/sim/lib/table/columns/service.ts @@ -15,6 +15,7 @@ import { createLogger } from '@sim/logger' import { and, count, eq, sql } from 'drizzle-orm' import { columnMatchesRef, generateColumnId, getColumnId } from '@/lib/table/column-keys' import { COLUMN_TYPES, NAME_PATTERN, TABLE_LIMITS } from '@/lib/table/constants' +import { assertColumnDestructive, assertSchemaMutable } from '@/lib/table/mutation-locks' import type { DbTransaction } from '@/lib/table/planner' import { stripGroupExecutions } from '@/lib/table/rows/executions' import { selectValueToNames } from '@/lib/table/select-values' @@ -67,6 +68,7 @@ export async function addTableColumn( requestId: string ): Promise { return withLockedTable(tableId, async (table, trx) => { + assertSchemaMutable(table) if (!NAME_PATTERN.test(column.name)) { throw new Error( `Invalid column name "${column.name}". Must start with a letter or underscore and contain only alphanumeric characters and underscores.` @@ -178,6 +180,7 @@ export async function renameColumn( requestId: string ): Promise { return withLockedTable(data.tableId, async (table, trx) => { + assertSchemaMutable(table) if (!NAME_PATTERN.test(data.newName)) { throw new Error( `Invalid column name "${data.newName}". Column names must start with a letter or underscore, followed by alphanumeric characters or underscores.` @@ -312,6 +315,7 @@ export async function deleteColumn( requestId: string ): Promise { const { def, stripKey } = await withLockedTable(data.tableId, async (table, trx) => { + assertColumnDestructive(table) const schema = table.schema const columnIndex = schema.columns.findIndex((c) => columnMatchesRef(c, data.columnName)) if (columnIndex === -1) { @@ -390,6 +394,7 @@ export async function deleteColumns( requestId: string ): Promise { const { def, stripKeys } = await withLockedTable(data.tableId, async (table, trx) => { + assertColumnDestructive(table) const schema = table.schema const namesToDelete = new Set() const idsToDelete = new Set() @@ -482,6 +487,8 @@ export async function updateColumnType( requestId: string ): Promise { return withLockedTable(data.tableId, async (table, trx) => { + // Retype reinterprets every stored value under a new type — destructive. + assertColumnDestructive(table) // Scale both statement and idle timeouts to row count: the compatibility // check below iterates every row in Node between the row SELECT and the // schema UPDATE, leaving the transaction idle for that gap. The default 5s @@ -645,6 +652,7 @@ export async function updateColumnConstraints( requestId: string ): Promise { return withLockedTable(data.tableId, async (table, trx) => { + assertSchemaMutable(table) // Scale both statement and idle timeouts to row count: the required/unique // validation runs between separate queries inside this transaction, leaving // it briefly idle. Match `updateColumnType` so the default 5s diff --git a/apps/sim/lib/table/delete-runner.test.ts b/apps/sim/lib/table/delete-runner.test.ts index 6894b59e443..575e04b3c58 100644 --- a/apps/sim/lib/table/delete-runner.test.ts +++ b/apps/sim/lib/table/delete-runner.test.ts @@ -74,8 +74,20 @@ describe('runTableDelete', () => { 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(mockDeletePageByIds).toHaveBeenNthCalledWith( + 1, + 'tbl_1', + 'ws_1', + ['a', 'b'], + expect.anything() + ) + expect(mockDeletePageByIds).toHaveBeenNthCalledWith( + 2, + 'tbl_1', + 'ws_1', + ['c'], + expect.anything() + ) expect(mockMarkJobReady).toHaveBeenCalledWith('tbl_1', 'job_1') expect(mockAppendTableEvent).toHaveBeenCalledWith( expect.objectContaining({ kind: 'job', type: 'delete', status: 'ready', progress: 3 }) @@ -104,7 +116,7 @@ describe('runTableDelete', () => { await runTableDelete(basePayload({ excludeRowIds: ['keep'] })) expect(mockDeletePageByIds).toHaveBeenCalledTimes(1) - expect(mockDeletePageByIds).toHaveBeenCalledWith('tbl_1', 'ws_1', ['x']) + expect(mockDeletePageByIds).toHaveBeenCalledWith('tbl_1', 'ws_1', ['x'], expect.anything()) // Second page is queried after the last id of the first page (cursor advanced past 'keep'). expect(mockSelectRowIdPage).toHaveBeenNthCalledWith( 2, diff --git a/apps/sim/lib/table/delete-runner.ts b/apps/sim/lib/table/delete-runner.ts index 024daab6afc..bdc47a77d1c 100644 --- a/apps/sim/lib/table/delete-runner.ts +++ b/apps/sim/lib/table/delete-runner.ts @@ -11,6 +11,7 @@ import { markJobReady, updateJobProgress, } from '@/lib/table/jobs/service' +import { unsafeMutationProof } from '@/lib/table/mutation-locks' import { deletePageByIds, selectRowIdPage } from '@/lib/table/rows/ordering' import { getTableById } from '@/lib/table/service' import { buildFilterClause } from '@/lib/table/sql' @@ -66,6 +67,13 @@ export async function runTableDelete(payload: TableDeletePayload): Promise const table = await getTableById(tableId, { includeArchived: true }) if (!table) throw new Error(`Delete target table ${tableId} not found`) + // The delete lock is asserted at the enqueue site (delete-async route), which + // is what gates *starting* the job. A job already admitted runs to completion + // — committed pages are never rolled back, and an admin cancels it to stop — + // so the runner is a trusted continuation and does not re-assert (which would + // also retry-storm this maxAttempts:3 task on a mid-job lock). + const deleteProof = unsafeMutationProof<'delete'>() + const filterClause = filter ? buildFilterClause(filter, USER_TABLE_ROWS_SQL_NAME, table.schema.columns) : undefined @@ -102,7 +110,7 @@ export async function runTableDelete(payload: TableDeletePayload): Promise const toDelete = excluded.size > 0 ? page.filter((id) => !excluded.has(id)) : page if (toDelete.length > 0) { - processed += await deletePageByIds(tableId, workspaceId, toDelete) + processed += await deletePageByIds(tableId, workspaceId, toDelete, deleteProof) } if ( diff --git a/apps/sim/lib/table/events.ts b/apps/sim/lib/table/events.ts index 7d1c8135fe0..c088a28793d 100644 --- a/apps/sim/lib/table/events.ts +++ b/apps/sim/lib/table/events.ts @@ -141,6 +141,15 @@ export type TableEvent = dispatchId?: string message: string } + | { + /** The table definition changed in a way that isn't row/cell data — a + * lock toggle or a schema mutation. Carries no payload; the client just + * invalidates the table-detail query so every open viewer re-reads the + * fresh locks/schema (otherwise an idle grid stays stale and writes 423). */ + kind: 'definition' + tableId: string + reason: 'locks' | 'schema' + } export interface TableEventEntry { eventId: number diff --git a/apps/sim/lib/table/import-data.ts b/apps/sim/lib/table/import-data.ts index 01740624e86..06a2f291fcb 100644 --- a/apps/sim/lib/table/import-data.ts +++ b/apps/sim/lib/table/import-data.ts @@ -11,6 +11,7 @@ import { generateId } from '@sim/utils/id' import { eq } from 'drizzle-orm' import { assertRowCapacity, notifyTableRowUsage } from '@/lib/table/billing' import { CSV_MAX_BATCH_SIZE } from '@/lib/table/import' +import { assertRowDelete, assertRowInsert, assertSchemaMutable } from '@/lib/table/mutation-locks' import { nKeysBetween } from '@/lib/table/order-key' import { acquireRowOrderLock } from '@/lib/table/rows/ordering' import { batchInsertRowsWithTx, replaceTableRowsWithTx } from '@/lib/table/rows/service' @@ -62,6 +63,8 @@ export async function bulkInsertImportBatch( table: TableDefinition, requestId: string ): Promise<{ inserted: number; lastOrderKey: string | null }> { + assertRowInsert(table) + for (let i = 0; i < data.rows.length; i++) { const sizeValidation = validateRowSize(data.rows[i]) if (!sizeValidation.valid) { @@ -113,8 +116,9 @@ export async function bulkInsertImportBatch( } /** Deletes every row of a table (set-based; the statement-level trigger zeroes `row_count`). */ -export async function deleteAllTableRows(tableId: string): Promise { - await db.delete(userTableRows).where(eq(userTableRows.tableId, tableId)) +export async function deleteAllTableRows(table: TableDefinition): Promise { + assertRowDelete(table) + await db.delete(userTableRows).where(eq(userTableRows.tableId, table.id)) } /** @@ -139,11 +143,15 @@ export async function addImportColumns( } /** Overwrites a table's schema during an import (used when inferring columns from the file). */ -export async function setTableSchemaForImport(tableId: string, schema: TableSchema): Promise { +export async function setTableSchemaForImport( + table: TableDefinition, + schema: TableSchema +): Promise { + assertSchemaMutable(table) await db .update(userTableDefinitions) .set({ schema, updatedAt: new Date() }) - .where(eq(userTableDefinitions.id, tableId)) + .where(eq(userTableDefinitions.id, table.id)) } /** diff --git a/apps/sim/lib/table/import-runner.ts b/apps/sim/lib/table/import-runner.ts index 85f85875577..91885fc5378 100644 --- a/apps/sim/lib/table/import-runner.ts +++ b/apps/sim/lib/table/import-runner.ts @@ -159,7 +159,7 @@ export async function runTableImport(payload: TableImportPayload): Promise // the same ids). schema = withGeneratedColumnIds({ columns: inferred.columns.map(normalizeColumn) }) headerToColumn = inferred.headerToColumn - await setTableSchemaForImport(tableId, schema) + await setTableSchemaForImport(table, schema) return } @@ -204,7 +204,7 @@ export async function runTableImport(payload: TableImportPayload): Promise // Replace deletes existing rows only after schema/mapping validation passes, so an // invalid or empty file fails the import with the old rows still intact (a mid-stream // insert failure after this point leaves a partial replace — replace is destructive). - if (mode === 'replace') await deleteAllTableRows(tableId) + if (mode === 'replace') await deleteAllTableRows(table) } const flush = async (rows: Record[]) => { diff --git a/apps/sim/lib/table/mutation-locks.test.ts b/apps/sim/lib/table/mutation-locks.test.ts new file mode 100644 index 00000000000..a51a7f00ba4 --- /dev/null +++ b/apps/sim/lib/table/mutation-locks.test.ts @@ -0,0 +1,111 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { + assertColumnDestructive, + assertRowDelete, + assertRowInsert, + assertRowUpdate, + assertSchemaMutable, + TableLockedError, +} from '@/lib/table/mutation-locks' +import type { TableDefinition, TableLocks } from '@/lib/table/types' + +const UNLOCKED: TableLocks = { + schemaLocked: false, + insertLocked: false, + updateLocked: false, + deleteLocked: false, +} + +function makeTable( + locks: Partial = {}, + columns: TableDefinition['schema']['columns'] = [] +): TableDefinition { + return { + id: 'tbl_1', + name: 't', + schema: { columns }, + rowCount: 0, + maxRows: 100, + workspaceId: 'ws_1', + createdBy: 'user_1', + locks: { ...UNLOCKED, ...locks }, + createdAt: new Date(), + updatedAt: new Date(), + } +} + +describe('mutation-locks', () => { + it('passes every assert on an unlocked table', () => { + const table = makeTable() + expect(() => assertRowInsert(table)).not.toThrow() + expect(() => assertRowUpdate(table, ['col_a'])).not.toThrow() + expect(() => assertRowDelete(table)).not.toThrow() + expect(() => assertSchemaMutable(table)).not.toThrow() + expect(() => assertColumnDestructive(table)).not.toThrow() + }) + + it('blocks only its own verb with a 423 and the right lock name', () => { + expect(() => assertRowInsert(makeTable({ insertLocked: true }))).toThrowError( + expect.objectContaining({ statusCode: 423, lock: 'insert' }) + ) + expect(() => assertRowUpdate(makeTable({ updateLocked: true }), ['col_a'])).toThrowError( + expect.objectContaining({ statusCode: 423, lock: 'update' }) + ) + expect(() => assertRowDelete(makeTable({ deleteLocked: true }))).toThrowError( + expect.objectContaining({ statusCode: 423, lock: 'delete' }) + ) + expect(() => assertSchemaMutable(makeTable({ schemaLocked: true }))).toThrowError( + expect.objectContaining({ statusCode: 423, lock: 'schema' }) + ) + }) + + it('does not cross-block: an insert lock permits update/delete/schema', () => { + const table = makeTable({ insertLocked: true }) + expect(() => assertRowUpdate(table, ['col_a'])).not.toThrow() + expect(() => assertRowDelete(table)).not.toThrow() + expect(() => assertSchemaMutable(table)).not.toThrow() + }) + + describe('update lock exemptions', () => { + it('permits an empty (executions-only) patch', () => { + expect(() => assertRowUpdate(makeTable({ updateLocked: true }))).not.toThrow() + expect(() => assertRowUpdate(makeTable({ updateLocked: true }), [])).not.toThrow() + }) + + it('permits a patch touching only workflow-group output columns', () => { + const table = makeTable({ updateLocked: true }, [ + { id: 'col_wf', name: 'Enriched', type: 'string', workflowGroupId: 'grp_1' }, + ]) + expect(() => assertRowUpdate(table, ['col_wf'])).not.toThrow() + }) + + it('blocks a patch touching a user-authored column', () => { + const table = makeTable({ updateLocked: true }, [ + { id: 'col_wf', name: 'Enriched', type: 'string', workflowGroupId: 'grp_1' }, + { id: 'col_user', name: 'Name', type: 'string' }, + ]) + expect(() => assertRowUpdate(table, ['col_wf', 'col_user'])).toThrow(TableLockedError) + }) + }) + + describe('assertColumnDestructive', () => { + it('requires BOTH schema and delete clear', () => { + expect(() => assertColumnDestructive(makeTable({ schemaLocked: true }))).toThrowError( + expect.objectContaining({ lock: 'schema' }) + ) + // schema clear but delete locked → delete error (destroying row data) + expect(() => assertColumnDestructive(makeTable({ deleteLocked: true }))).toThrowError( + expect.objectContaining({ lock: 'delete' }) + ) + }) + }) + + it('treats an absent locks field as fully unlocked (fail-open for mocks)', () => { + const table = { ...makeTable(), locks: undefined } as unknown as TableDefinition + expect(() => assertRowInsert(table)).not.toThrow() + expect(() => assertSchemaMutable(table)).not.toThrow() + }) +}) diff --git a/apps/sim/lib/table/mutation-locks.ts b/apps/sim/lib/table/mutation-locks.ts new file mode 100644 index 00000000000..856ae4d998d --- /dev/null +++ b/apps/sim/lib/table/mutation-locks.ts @@ -0,0 +1,176 @@ +/** + * Per-table mutation-lock enforcement — four independent locks, one per verb. + * + * Lives at the `lib/table` service layer, not the routes: Mothership calls these + * services directly, so route-level asserts would miss it entirely. + * + * Non-goals, each load-bearing: + * - Do NOT re-read the table inside a write tx to close the TOCTOU window. A + * `SELECT ... FOR UPDATE` on the definition row reintroduces the per-insert + * serialization hotspot migration 0198 removed; `getTableById` without `{ tx }` + * takes a second pool connection while holding the first (the billing + * pool-starvation shape). Locks gate *starting* a mutation, not a transactional + * fence — workspace `write` is still the security boundary. + * - Integrity, not confidentiality: reads/exports are never blocked, and forking + * (admin-gated, the same bar as unlocking) resets locks on the copy. + * - System writers outside these helpers (fork copy, workspace archive, + * soft-delete GC, order-key repair, seeds) must keep writing raw so they are + * never blocked. + */ + +import { createLogger } from '@sim/logger' +import { HttpError } from '@/lib/core/utils/http-error' +import { getColumnId } from '@/lib/table/column-keys' +import type { RowData, TableDefinition, TableLockKind } from '@/lib/table/types' + +const logger = createLogger('TableMutationLocks') + +const LOCK_MESSAGES: Record = { + schema: 'This table is schema-locked: its columns cannot be changed.', + insert: 'This table is insert-locked: new rows cannot be added.', + update: 'This table is update-locked: existing rows cannot be edited.', + delete: 'This table is delete-locked: rows cannot be deleted.', +} + +/** + * Thrown when a mutation violates a table lock. Extends {@link HttpError} with + * `statusCode = 423` so `withRouteHandler` forwards the message on routes that + * don't catch it, and route catch blocks map it via `tableLockErrorResponse`. + * The message names the lock and is retry-pointless — surface it verbatim. + */ +export class TableLockedError extends HttpError { + readonly statusCode = 423 + readonly lock: TableLockKind + + constructor(lock: TableLockKind, message?: string) { + super(message ?? LOCK_MESSAGES[lock]) + this.name = 'TableLockedError' + this.lock = lock + } +} + +/** + * An opaque, unforgeable proof that a lock assert ran for a verb. The brand + * symbol is module-private, so no other module can mint one by object literal + * or cast — the only source is the `assert*` functions here (and the explicit + * test/bypass escape hatch). The low-level write primitives in `rows/ordering.ts` + * require the matching proof, so a new row-write path cannot compile without + * first asserting. + */ +declare const proofBrand: unique symbol +export interface MutationProof { + readonly [proofBrand]: V +} + +const PROOF = Object.freeze({}) as MutationProof + +function proofFor(): MutationProof { + return PROOF as MutationProof +} + +function logBlocked(table: TableDefinition, lock: TableLockKind): void { + logger.warn('Table mutation blocked by lock', { + tableId: table.id, + workspaceId: table.workspaceId, + lock, + }) +} + +/** Asserts the table permits inserting rows. */ +export function assertRowInsert(table: TableDefinition): MutationProof<'insert'> { + if (table.locks?.insertLocked) { + logBlocked(table, 'insert') + throw new TableLockedError('insert') + } + return proofFor<'insert'>() +} + +/** Asserts the table permits deleting rows. */ +export function assertRowDelete(table: TableDefinition): MutationProof<'delete'> { + if (table.locks?.deleteLocked) { + logBlocked(table, 'delete') + throw new TableLockedError('delete') + } + return proofFor<'delete'>() +} + +/** + * Asserts the table permits updating existing rows. + * + * Two carve-outs keep the update lock meaning "user-authored data is immutable" + * without breaking the machinery that lives on the same write path: + * + * - An **empty data patch** (`columnIds` omitted or empty) is an + * executions-only write — cancellation, error/completed status stamps, and + * usage-limit pre-stamp clears. These are not row edits; blocking them would + * strand cells in `running` forever, so they always pass. + * - A patch that touches **only workflow-group output columns** is a computed + * write (enrichment / workflow column), not a user edit, so it passes even on + * an update-locked table. A patch touching any user-authored column is blocked. + */ +export function assertRowUpdate( + table: TableDefinition, + columnIds?: readonly string[] +): MutationProof<'update'> { + if (!table.locks?.updateLocked) return proofFor<'update'>() + if (!columnIds || columnIds.length === 0) return proofFor<'update'>() + if (patchTouchesOnlyWorkflowColumns(table, columnIds)) return proofFor<'update'>() + logBlocked(table, 'update') + throw new TableLockedError('update') +} + +/** Asserts the table permits schema (column / workflow-group) changes. */ +export function assertSchemaMutable(table: TableDefinition): MutationProof<'schema'> { + if (table.locks?.schemaLocked) { + logBlocked(table, 'schema') + throw new TableLockedError('schema') + } + return proofFor<'schema'>() +} + +/** + * Asserts a **destructive** schema change (dropping or retyping a column, or + * deleting a workflow group/output). These rewrite `user_table_rows.data` for + * every row, so they are gated by BOTH the schema lock (structure) and the + * delete lock (data destruction) — a delete lock therefore genuinely means "no + * row data disappears by any route", including via column removal. + */ +export function assertColumnDestructive(table: TableDefinition): void { + assertSchemaMutable(table) + if (table.locks?.deleteLocked) { + logBlocked(table, 'delete') + throw new TableLockedError('delete') + } +} + +/** + * True when every id in `columnIds` maps to a workflow-group output column in + * the table's schema. Used by {@link assertRowUpdate} to exempt computed-cell + * writes from the update lock. An unknown id (not in the schema) counts as + * user-authored, so it fails closed. + */ +function patchTouchesOnlyWorkflowColumns( + table: TableDefinition, + columnIds: readonly string[] +): boolean { + const workflowColumnIds = new Set() + for (const col of table.schema.columns) { + if (col.workflowGroupId) workflowColumnIds.add(getColumnId(col)) + } + return columnIds.every((id) => workflowColumnIds.has(id)) +} + +/** Column ids present in a row-data patch, for {@link assertRowUpdate}. */ +export function patchColumnIds(data: RowData): string[] { + return Object.keys(data) +} + +/** + * Escape hatch for tests and trusted system callers that legitimately invoke + * the low-level `rows/ordering.ts` primitives without a preceding assert + * (e.g. fixtures, or a call path already gated elsewhere). NOT for production + * mutation paths — those must assert so locks are enforced and violations logged. + */ +export function unsafeMutationProof(): MutationProof { + return proofFor() +} diff --git a/apps/sim/lib/table/rows/ordering.ts b/apps/sim/lib/table/rows/ordering.ts index 368c9959d9e..6530dcd87aa 100644 --- a/apps/sim/lib/table/rows/ordering.ts +++ b/apps/sim/lib/table/rows/ordering.ts @@ -11,6 +11,7 @@ import { userTableRows } from '@sim/db/schema' import { and, asc, desc, eq, gt, inArray, lt, lte, type SQL, sql } from 'drizzle-orm' import type { DbOrTx } from '@/lib/db/types' import { TABLE_LIMITS } from '@/lib/table/constants' +import type { MutationProof } from '@/lib/table/mutation-locks' import { keyBetween, nKeysBetween } from '@/lib/table/order-key' import { type DbExecutor, type DbTransaction, withSeqscanOff } from '@/lib/table/planner' import { setTableTxTimeouts } from '@/lib/table/tx' @@ -194,6 +195,8 @@ export async function insertOrderedRow(params: { beforeRowId?: string createdBy?: string now: Date + /** Proof the caller asserted the insert lock (see `mutation-locks.ts`). */ + proof: MutationProof<'insert'> }): Promise<{ id: string data: RowData @@ -252,6 +255,8 @@ export async function deleteOrderedRow(params: { tableId: string rowId: string workspaceId: string + /** Proof the caller asserted the delete lock (see `mutation-locks.ts`). */ + proof: MutationProof<'delete'> }): Promise { const { tableId, rowId, workspaceId } = params return db.transaction(async (trx) => { @@ -280,6 +285,8 @@ export async function deleteOrderedRowsByIds(params: { tableId: string workspaceId: string rowIds: string[] + /** Proof the caller asserted the delete lock (see `mutation-locks.ts`). */ + proof: MutationProof<'delete'> }): Promise<{ id: string }[]> { const { tableId, workspaceId, rowIds } = params if (rowIds.length === 0) return [] @@ -401,7 +408,9 @@ export async function selectRowDataPage(params: { export async function deletePageByIds( tableId: string, workspaceId: string, - rowIds: string[] + rowIds: string[], + /** Proof the caller asserted the delete lock (see `mutation-locks.ts`). */ + _proof: MutationProof<'delete'> ): Promise { let deleted = 0 for (let i = 0; i < rowIds.length; i += TABLE_LIMITS.DELETE_BATCH_SIZE) { @@ -433,7 +442,9 @@ export async function updatePageByIds( tableId: string, workspaceId: string, rowIds: string[], - patchJson: string + patchJson: string, + /** Proof the caller asserted the update lock (see `mutation-locks.ts`). */ + _proof: MutationProof<'update'> ): Promise { const now = new Date() let updated = 0 diff --git a/apps/sim/lib/table/rows/service.ts b/apps/sim/lib/table/rows/service.ts index 8e10b62ce80..29660a2181e 100644 --- a/apps/sim/lib/table/rows/service.ts +++ b/apps/sim/lib/table/rows/service.ts @@ -25,6 +25,12 @@ import { } from '@/lib/table/billing' import { getColumnId } from '@/lib/table/column-keys' import { getMaxPageBytes, TABLE_LIMITS, USER_TABLE_ROWS_SQL_NAME } from '@/lib/table/constants' +import { + assertRowDelete, + assertRowInsert, + assertRowUpdate, + patchColumnIds, +} from '@/lib/table/mutation-locks' import { nKeysBetween } from '@/lib/table/order-key' import { type DbExecutor, type DbTransaction, withSeqscanOff } from '@/lib/table/planner' import { @@ -99,6 +105,8 @@ export async function insertRow( table: TableDefinition, requestId: string ): Promise { + const insertProof = assertRowInsert(table) + // Validate row size const sizeValidation = validateRowSize(data.data) if (!sizeValidation.valid) { @@ -140,6 +148,7 @@ export async function insertRow( beforeRowId: data.beforeRowId, createdBy: data.userId, now, + proof: insertProof, }) notifyTableRowUsage({ @@ -231,6 +240,8 @@ export async function batchInsertRowsWithTx( table: TableDefinition, requestId: string ): Promise { + assertRowInsert(table) + for (let i = 0; i < data.rows.length; i++) { const row = data.rows[i] @@ -382,6 +393,9 @@ export async function replaceTableRowsWithTx( table: TableDefinition, requestId: string ): Promise { + assertRowDelete(table) + assertRowInsert(table) + if (data.tableId !== table.id) { throw new Error(`Table ID mismatch: ${data.tableId} vs ${table.id}`) } @@ -626,6 +640,7 @@ export async function upsertRow( } if (matchedRowId) { + assertRowUpdate(table, patchColumnIds(data.data)) const [updatedRow] = await trx .update(userTableRows) .set({ data: data.data, updatedAt: now }) @@ -648,6 +663,8 @@ export async function upsertRow( } } + assertRowInsert(table) + if (wouldExceedRowLimit(rowLimit, table.rowCount, 1)) { throw new TableRowLimitError(rowLimit) } @@ -1165,6 +1182,8 @@ export async function updateRow( requestId: string, options: UpdateRowOptions = {} ): Promise { + assertRowUpdate(table, patchColumnIds(data.data)) + // Get existing row const existingRow = await getRowById(data.tableId, data.rowId, data.workspaceId) if (!existingRow) { @@ -1347,15 +1366,20 @@ export async function updateRow( * @throws Error if row not found */ export async function deleteRow( - tableId: string, + table: TableDefinition, rowId: string, - workspaceId: string, requestId: string ): Promise { - const deleted = await deleteOrderedRow({ tableId, rowId, workspaceId }) + const proof = assertRowDelete(table) + const deleted = await deleteOrderedRow({ + tableId: table.id, + rowId, + workspaceId: table.workspaceId, + proof, + }) if (!deleted) throw new Error('Row not found') - logger.info(`[${requestId}] Deleted row ${rowId} from table ${tableId}`) + logger.info(`[${requestId}] Deleted row ${rowId} from table ${table.id}`) } /** @@ -1371,6 +1395,8 @@ export async function updateRowsByFilter( data: BulkUpdateData, requestId: string ): Promise { + assertRowUpdate(table, patchColumnIds(data.data)) + const tableName = USER_TABLE_ROWS_SQL_NAME const filterClause = buildFilterClause(data.filter, tableName, table.schema.columns) @@ -1513,6 +1539,12 @@ export async function batchUpdateRows( return { affectedCount: 0, affectedRowIds: [] } } + // Also the workflow-column backfill write path. + assertRowUpdate( + table, + data.updates.flatMap((u) => patchColumnIds(u.data)) + ) + const rowIds = data.updates.map((u) => u.rowId) const existingRows = await db .select({ @@ -1712,6 +1744,8 @@ export async function deleteRowsByFilter( data: BulkDeleteData, requestId: string ): Promise { + const proof = assertRowDelete(table) + const tableName = USER_TABLE_ROWS_SQL_NAME // Build filter clause @@ -1748,6 +1782,7 @@ export async function deleteRowsByFilter( tableId: table.id, workspaceId: table.workspaceId, rowIds, + proof, }) logger.info(`[${requestId}] Deleted ${matchingRows.length} rows from table ${table.id}`) @@ -1766,15 +1801,19 @@ export async function deleteRowsByFilter( * @returns Deletion result with deleted/missing row IDs */ export async function deleteRowsByIds( + table: TableDefinition, data: BulkDeleteByIdsData, requestId: string ): Promise { + const proof = assertRowDelete(table) + const uniqueRequestedRowIds = Array.from(new Set(data.rowIds)) const deletedRows = await deleteOrderedRowsByIds({ tableId: data.tableId, workspaceId: data.workspaceId, rowIds: uniqueRequestedRowIds, + proof, }) const deletedIds = deletedRows.map((r) => r.id) diff --git a/apps/sim/lib/table/service.ts b/apps/sim/lib/table/service.ts index 90f3c6f6e7a..3a76842f2ed 100644 --- a/apps/sim/lib/table/service.ts +++ b/apps/sim/lib/table/service.ts @@ -19,15 +19,19 @@ import type { DbOrTx } from '@/lib/db/types' import { assertRowCapacity, notifyTableRowUsage } from '@/lib/table/billing' import { generateColumnId, getColumnId, withGeneratedColumnIds } from '@/lib/table/column-keys' import { COLUMN_TYPES, NAME_PATTERN, TABLE_LIMITS } from '@/lib/table/constants' +import { appendTableEvent } from '@/lib/table/events' import { EMPTY_JOB_FIELDS, latestJobForTable, latestJobsForTables } from '@/lib/table/jobs/service' +import { assertSchemaMutable, TableLockedError } from '@/lib/table/mutation-locks' import { nKeysBetween } from '@/lib/table/order-key' import type { DbTransaction } from '@/lib/table/planner' import { setTableTxTimeouts } from '@/lib/table/tx' -import type { - CreateTableData, - TableDefinition, - TableMetadata, - TableSchema, +import { + type CreateTableData, + type TableDefinition, + type TableLocks, + type TableMetadata, + type TableSchema, + UNLOCKED_TABLE_LOCKS, } from '@/lib/table/types' import { validateTableName, validateTableSchema } from '@/lib/table/validation' import { stripGroupDeps } from '@/lib/table/workflow-columns' @@ -43,6 +47,32 @@ export class TableConflictError extends Error { export type TableScope = 'active' | 'archived' | 'all' +/** + * Lock columns, selected together and read into a {@link TableLocks}. Kept + * beside `readLocks` so `getTableById`/`listTables` never drift — a missing + * column would surface `locks` as all-false and silently disable enforcement. + */ +const LOCK_SELECT = { + schemaLocked: userTableDefinitions.schemaLocked, + insertLocked: userTableDefinitions.insertLocked, + updateLocked: userTableDefinitions.updateLocked, + deleteLocked: userTableDefinitions.deleteLocked, +} as const + +function readLocks(row: { + schemaLocked: boolean + insertLocked: boolean + updateLocked: boolean + deleteLocked: boolean +}): TableLocks { + return { + schemaLocked: row.schemaLocked, + insertLocked: row.insertLocked, + updateLocked: row.updateLocked, + deleteLocked: row.deleteLocked, + } +} + /** * Serializes schema/metadata read-modify-writes for a single table so * concurrent mutators can't clobber each other's `schema` JSONB @@ -133,6 +163,7 @@ export async function getTableById( createdAt: userTableDefinitions.createdAt, updatedAt: userTableDefinitions.updatedAt, rowCount: userTableDefinitions.rowCount, + ...LOCK_SELECT, }) .from(userTableDefinitions) .where( @@ -157,6 +188,7 @@ export async function getTableById( maxRows: table.maxRows, workspaceId: table.workspaceId, createdBy: table.createdBy, + locks: readLocks(table), archivedAt: table.archivedAt, createdAt: table.createdAt, updatedAt: table.updatedAt, @@ -189,6 +221,7 @@ export async function listTables( createdAt: userTableDefinitions.createdAt, updatedAt: userTableDefinitions.updatedAt, rowCount: userTableDefinitions.rowCount, + ...LOCK_SELECT, }) .from(userTableDefinitions) .where( @@ -221,6 +254,7 @@ export async function listTables( maxRows: t.maxRows, workspaceId: t.workspaceId, createdBy: t.createdBy, + locks: readLocks(t), archivedAt: t.archivedAt, createdAt: t.createdAt, updatedAt: t.updatedAt, @@ -392,6 +426,7 @@ export async function createTable( maxRows: newTable.maxRows, workspaceId: newTable.workspaceId, createdBy: newTable.createdBy, + locks: UNLOCKED_TABLE_LOCKS, archivedAt: newTable.archivedAt, createdAt: newTable.createdAt, updatedAt: newTable.updatedAt, @@ -421,6 +456,10 @@ export async function addTableColumnsWithTx( ): Promise { if (columns.length === 0) return table + // Runs outside `withLockedTable` (reachable from CSV import with new + // headers), so it must assert directly. + assertSchemaMutable(table) + const usedNames = new Set(table.schema.columns.map((c) => c.name.toLowerCase())) const additions: TableSchema['columns'] = [] @@ -573,6 +612,52 @@ export async function renameTable( } } +/** + * Applies a partial lock change to a table. The caller authorizes (admin-only); + * this function does the write. + * + * Writes inside `withLockedTable` so the toggle is serialized against schema + * mutations on the same table — a lock can't be flipped in the middle of a + * concurrent column change, closing the check-then-act window on the mutators + * that assert under that same advisory lock. Deliberately asserts NO lock on + * itself, so a fully-locked table stays togglable (the reversibility carve-out). + * Emits a `definition` SSE event so other open viewers refresh their lock state. + */ +export async function updateTableLocks( + tableId: string, + partial: Partial, + actingUserId: string, + requestId: string +): Promise { + const updated = await withLockedTable(tableId, async (table, trx) => { + const nextLocks: TableLocks = { ...table.locks, ...partial } + const now = new Date() + await trx + .update(userTableDefinitions) + .set({ ...nextLocks, updatedAt: now }) + .where(eq(userTableDefinitions.id, tableId)) + return { ...table, locks: nextLocks, updatedAt: now } + }) + + recordAudit({ + workspaceId: updated.workspaceId, + actorId: actingUserId, + action: AuditAction.TABLE_UPDATED, + resourceType: AuditResourceType.TABLE, + resourceId: tableId, + resourceName: updated.name, + description: 'Updated table locks', + metadata: { op: 'update_locks', locks: updated.locks }, + }) + + await appendTableEvent({ kind: 'definition', tableId, reason: 'locks' }).catch((error) => { + logger.warn(`[${requestId}] Failed to emit lock-change event for table ${tableId}`, { error }) + }) + + logger.info(`[${requestId}] Updated locks for table ${tableId}`) + return updated +} + /** * Updates a table's metadata (UI state like column widths/order, plus behavioral * settings like `workflowColumnBatchSize`). Merges into the existing metadata blob. @@ -650,10 +735,19 @@ export async function deleteTable( actingUserId?: string ): Promise { const now = new Date() + // Archiving destroys access to every row, so it is gated on the delete lock. + // The guard is inline in the WHERE (atomic — no separate read, no TOCTOU); + // a zero-row result is then disambiguated below (locked vs already-archived). const result = await db .update(userTableDefinitions) .set({ archivedAt: now, updatedAt: now }) - .where(and(eq(userTableDefinitions.id, tableId), isNull(userTableDefinitions.archivedAt))) + .where( + and( + eq(userTableDefinitions.id, tableId), + isNull(userTableDefinitions.archivedAt), + eq(userTableDefinitions.deleteLocked, false) + ) + ) .returning({ createdBy: userTableDefinitions.createdBy, workspaceId: userTableDefinitions.workspaceId, @@ -661,6 +755,26 @@ export async function deleteTable( }) const deleted = result[0] + if (!deleted) { + const [existing] = await db + .select({ + archivedAt: userTableDefinitions.archivedAt, + deleteLocked: userTableDefinitions.deleteLocked, + workspaceId: userTableDefinitions.workspaceId, + }) + .from(userTableDefinitions) + .where(eq(userTableDefinitions.id, tableId)) + .limit(1) + if (existing && !existing.archivedAt && existing.deleteLocked) { + logger.warn('Table mutation blocked by lock', { + tableId, + workspaceId: existing.workspaceId, + lock: 'delete', + }) + throw new TableLockedError('delete') + } + // Otherwise the table is missing or already archived — a silent no-op, as before. + } // Audit only genuine user deletes — rollback callers omit `actingUserId`. The // caller emits the `table_deleted` PostHog event, so it is not duplicated here. if (deleted && actingUserId) { @@ -680,6 +794,12 @@ export async function deleteTable( /** * Restores an archived table. + * + * Deliberately NOT gated by any lock. Restore is additive — it clears the + * archive tombstone and renames to dodge the unique index; it destroys nothing. + * Gating it on the delete lock would strand a delete-locked table that was + * archived by a bypass path (e.g. workspace archive, which has no un-archive), + * making the lock the thing that permanently loses the data it protects. */ export async function restoreTable(tableId: string, requestId: string): Promise { const table = await getTableById(tableId, { includeArchived: true }) diff --git a/apps/sim/lib/table/types.ts b/apps/sim/lib/table/types.ts index 54150a27af2..420c5777348 100644 --- a/apps/sim/lib/table/types.ts +++ b/apps/sim/lib/table/types.ts @@ -348,6 +348,35 @@ export interface TableBackfillJobPayload { overwrite: boolean } +/** + * The four independent mutation verbs a table lock can guard. `schema` covers + * column/workflow-group structure; `insert`/`update`/`delete` cover row data. + * Shared by the DB columns, the wire contract, the {@link TableLockedError}, + * and the settings UI so all four agree on one source of truth. + */ +export const TABLE_LOCK_KINDS = ['schema', 'insert', 'update', 'delete'] as const +export type TableLockKind = (typeof TABLE_LOCK_KINDS)[number] + +/** + * Per-table mutation locks. Each flag independently forbids one verb. Enforced + * at the `lib/table` service layer (see `lib/table/mutation-locks.ts`). + * Append-only = `{ update: true, delete: true }`; read-only = all four true. + */ +export interface TableLocks { + schemaLocked: boolean + insertLocked: boolean + updateLocked: boolean + deleteLocked: boolean +} + +/** A fully-unlocked lock set — the state every new table is created in. */ +export const UNLOCKED_TABLE_LOCKS: TableLocks = { + schemaLocked: false, + insertLocked: false, + updateLocked: false, + deleteLocked: false, +} + export interface TableDefinition { id: string name: string @@ -358,6 +387,8 @@ export interface TableDefinition { maxRows: number workspaceId: string createdBy: string + /** Per-table mutation locks; absent-as-all-false is normalized on read. */ + locks: TableLocks archivedAt?: Date | string | null createdAt: Date | string updatedAt: Date | string diff --git a/apps/sim/lib/table/update-runner.test.ts b/apps/sim/lib/table/update-runner.test.ts index d220effe0fa..a1dc26248e1 100644 --- a/apps/sim/lib/table/update-runner.test.ts +++ b/apps/sim/lib/table/update-runner.test.ts @@ -99,14 +99,16 @@ describe('runTableUpdate', () => { 'tbl_1', 'ws_1', ['a', 'b'], - expect.any(String) + expect.any(String), + expect.anything() ) expect(mockUpdatePageByIds).toHaveBeenNthCalledWith( 2, 'tbl_1', 'ws_1', ['c'], - expect.any(String) + expect.any(String), + expect.anything() ) expect(mockMarkJobReady).toHaveBeenCalledWith('tbl_1', 'job_1') expect(mockAppendTableEvent).toHaveBeenCalledWith( diff --git a/apps/sim/lib/table/update-runner.ts b/apps/sim/lib/table/update-runner.ts index 194746830c9..0e5c8ab0dc3 100644 --- a/apps/sim/lib/table/update-runner.ts +++ b/apps/sim/lib/table/update-runner.ts @@ -11,6 +11,7 @@ import { markJobReady, updateJobProgress, } from '@/lib/table/jobs/service' +import { unsafeMutationProof } from '@/lib/table/mutation-locks' import { selectRowDataPage, updatePageByIds } from '@/lib/table/rows/ordering' import { getTableById } from '@/lib/table/service' import { buildFilterClause } from '@/lib/table/sql' @@ -67,6 +68,11 @@ export async function runTableUpdate(payload: TableUpdatePayload): Promise const table = await getTableById(tableId, { includeArchived: true }) if (!table) throw new Error(`Update target table ${tableId} not found`) + // The update lock is asserted at the enqueue site; an admitted job runs to + // completion (committed pages stay; admin cancels to stop). Trusted + // continuation — see delete-runner for the full rationale. + const updateProof = unsafeMutationProof<'update'>() + const filterClause = buildFilterClause(filter, USER_TABLE_ROWS_SQL_NAME, table.schema.columns) if (!filterClause) throw new Error('Filter is required for bulk update') @@ -121,7 +127,8 @@ export async function runTableUpdate(payload: TableUpdatePayload): Promise tableId, workspaceId, page.map((r) => r.id), - patchJson + patchJson, + updateProof ) if ( diff --git a/apps/sim/lib/table/workflow-groups/service.ts b/apps/sim/lib/table/workflow-groups/service.ts index 8479118650d..c36a3d64a0e 100644 --- a/apps/sim/lib/table/workflow-groups/service.ts +++ b/apps/sim/lib/table/workflow-groups/service.ts @@ -19,6 +19,7 @@ import { remapGroupColumnRefs, } from '@/lib/table/column-keys' import { NAME_PATTERN, TABLE_LIMITS } from '@/lib/table/constants' +import { assertColumnDestructive, assertSchemaMutable } from '@/lib/table/mutation-locks' import { stripGroupExecutions } from '@/lib/table/rows/executions' import { getTableById, withLockedTable } from '@/lib/table/service' import { setTableTxTimeouts } from '@/lib/table/tx' @@ -48,6 +49,11 @@ const logger = createLogger('TableWorkflowGroupsService') * - Scoped to the workflow's workspace. * - Idempotent: running twice with the same `validBlockIds` is a no-op on the * second pass. Existing row data is left alone. + * + * Deliberately does NOT assert the schema lock: this is system reconciliation + * triggered by a workflow deploy, not a user schema edit, and it only prunes + * references to blocks that no longer exist. Blocking it on a schema-locked + * table would leave the table pointing at dead blocks. */ export async function pruneStaleWorkflowGroupOutputs({ workflowId, @@ -120,6 +126,7 @@ export async function addWorkflowGroup( requestId: string ): Promise { const updatedTable = await withLockedTable(data.tableId, async (table, trx) => { + assertSchemaMutable(table) const schema = table.schema const groups = schema.workflowGroups ?? [] if (groups.some((g) => g.id === data.group.id)) { @@ -280,6 +287,8 @@ export async function updateWorkflowGroup( const { updatedTable, added, remappedColumnIds, newOutputs, previousAutoRun } = await withLockedTable(data.tableId, async (table, trx) => { + // May drop/remap output columns and strip their row data — destructive. + assertColumnDestructive(table) await setTableTxTimeouts(trx, { statementMs: 60_000 }) const schema = table.schema @@ -655,6 +664,7 @@ export async function addWorkflowGroupOutput( // write. The critical section holds no I/O — just the in-memory splice + the // schema UPDATE — so concurrent adders queue behind it quickly. const { updatedTable, newOutput } = await withLockedTable(data.tableId, async (table, trx) => { + assertSchemaMutable(table) const schema = table.schema const groups = schema.workflowGroups ?? [] const groupIndex = groups.findIndex((g) => g.id === data.groupId) @@ -841,6 +851,7 @@ export async function deleteWorkflowGroupOutput( requestId: string ): Promise { return withLockedTable(data.tableId, async (table, trx) => { + assertColumnDestructive(table) const schema = table.schema const groups = schema.workflowGroups ?? [] const groupIndex = groups.findIndex((g) => g.id === data.groupId) @@ -907,6 +918,7 @@ export async function deleteWorkflowGroup( requestId: string ): Promise { return withLockedTable(data.tableId, async (table, trx) => { + assertColumnDestructive(table) const schema = table.schema const groups = schema.workflowGroups ?? [] const group = groups.find((g) => g.id === data.groupId) diff --git a/packages/db/migrations/0270_table_mutation_locks.sql b/packages/db/migrations/0270_table_mutation_locks.sql new file mode 100644 index 00000000000..d79f493d042 --- /dev/null +++ b/packages/db/migrations/0270_table_mutation_locks.sql @@ -0,0 +1,4 @@ +ALTER TABLE "user_table_definitions" ADD COLUMN "schema_locked" boolean DEFAULT false NOT NULL;--> statement-breakpoint +ALTER TABLE "user_table_definitions" ADD COLUMN "insert_locked" boolean DEFAULT false NOT NULL;--> statement-breakpoint +ALTER TABLE "user_table_definitions" ADD COLUMN "update_locked" boolean DEFAULT false NOT NULL;--> statement-breakpoint +ALTER TABLE "user_table_definitions" ADD COLUMN "delete_locked" boolean DEFAULT false NOT NULL; diff --git a/packages/db/migrations/meta/_journal.json b/packages/db/migrations/meta/_journal.json index cde0fe20a26..3b2a13dd198 100644 --- a/packages/db/migrations/meta/_journal.json +++ b/packages/db/migrations/meta/_journal.json @@ -1884,6 +1884,13 @@ "when": 1784911198876, "tag": "0269_late_spencer_smythe", "breakpoints": true + }, + { + "idx": 270, + "version": "7", + "when": 1784938826038, + "tag": "0270_table_mutation_locks", + "breakpoints": true } ] } diff --git a/packages/db/schema.ts b/packages/db/schema.ts index a13d0e01eee..3ac2c174b0f 100644 --- a/packages/db/schema.ts +++ b/packages/db/schema.ts @@ -3684,6 +3684,21 @@ export const userTableDefinitions = pgTable( * from application code — the trigger is the only writer (bypass-proof). */ rowsVersion: bigint('rows_version', { mode: 'number' }).notNull().default(0), + /** + * @remarks + * Per-table mutation locks. Each guards one mutation verb; an admin toggles + * them independently. Enforced at the `lib/table` service layer (see + * `lib/table/mutation-locks.ts`), which covers every entry point — routes, + * workflow blocks, and Mothership — since all funnel through those helpers. + * A locked verb rejects with 423; toggling a lock requires workspace admin. + * Append-only = update + delete locked; read-only = all four locked. These + * are integrity controls (against accidental/agentic mutation), not + * confidentiality controls — reads and exports are never blocked. + */ + schemaLocked: boolean('schema_locked').notNull().default(false), + insertLocked: boolean('insert_locked').notNull().default(false), + updateLocked: boolean('update_locked').notNull().default(false), + deleteLocked: boolean('delete_locked').notNull().default(false), archivedAt: timestamp('archived_at'), createdBy: text('created_by') .notNull() diff --git a/packages/testing/src/mocks/schema.mock.ts b/packages/testing/src/mocks/schema.mock.ts index 504c7bf3a94..2547322bba0 100644 --- a/packages/testing/src/mocks/schema.mock.ts +++ b/packages/testing/src/mocks/schema.mock.ts @@ -1107,6 +1107,11 @@ export const schemaMock = { metadata: 'metadata', maxRows: 'maxRows', rowCount: 'rowCount', + rowsVersion: 'rowsVersion', + schemaLocked: 'schemaLocked', + insertLocked: 'insertLocked', + updateLocked: 'updateLocked', + deleteLocked: 'deleteLocked', archivedAt: 'archivedAt', createdBy: 'createdBy', createdAt: 'createdAt', From b348cd06fa44381b4809c6199922d5fedc2a60d1 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Fri, 24 Jul 2026 23:33:42 -0700 Subject: [PATCH 02/15] =?UTF-8?q?fix(tables):=20address=20review=20?= =?UTF-8?q?=E2=80=94=20CI=20snapshot,=20lock-aware=20jobs,=20UI=20gating?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add the drizzle meta snapshot the hand-written migration was missing, which made CI regenerate the lock columns as a phantom 0271 - Stop a queued or retried delete/update job that starts after its lock is enabled; a run that has already committed pages still finishes, since pages are never rolled back and aborting would leave an uncancelled half-done state - Map TableLockedError to 423 on the internal single-row DELETE (was a 500) - Gate the lock settings UI behind NEXT_PUBLIC_TABLE_LOCKS so it can't open onto a Save that 403s against the server-side flag - Respect the delete lock on column drops in the grid, matching assertColumnDestructive, instead of failing only on click --- .../api/table/[tableId]/rows/[rowId]/route.ts | 4 + .../components/table-grid/table-grid.tsx | 17 +- .../table-locked-modal/table-locked-modal.tsx | 14 +- .../[workspaceId]/tables/[tableId]/table.tsx | 22 +- apps/sim/lib/core/config/env.ts | 2 + apps/sim/lib/table/delete-runner.test.ts | 27 +- apps/sim/lib/table/delete-runner.ts | 23 +- apps/sim/lib/table/update-runner.test.ts | 27 +- apps/sim/lib/table/update-runner.ts | 17 +- .../migrations/0270_table_mutation_locks.sql | 2 +- .../db/migrations/meta/0270_snapshot.json | 17720 ++++++++++++++++ packages/db/migrations/meta/_journal.json | 2 +- 12 files changed, 17856 insertions(+), 21 deletions(-) create mode 100644 packages/db/migrations/meta/0270_snapshot.json 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 0a1f0d56bd9..82ec048ca84 100644 --- a/apps/sim/app/api/table/[tableId]/rows/[rowId]/route.ts +++ b/apps/sim/app/api/table/[tableId]/rows/[rowId]/route.ts @@ -21,6 +21,7 @@ import { checkAccess, rootErrorMessage, rowWriteErrorResponse, + tableLockErrorResponse, } from '@/app/api/table/utils' const logger = createLogger('TableRowAPI') @@ -221,6 +222,9 @@ export const DELETE = withRouteHandler(async (request: NextRequest, context: Row }, }) } catch (error) { + const lockError = tableLockErrorResponse(error) + if (lockError) return lockError + const errorMessage = toError(error).message if (errorMessage === 'Row not found') { 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 f220dff3b96..481e31ab9cf 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 @@ -554,6 +554,10 @@ export function TableGrid({ const canEditCell = userPermissions.canEdit && !locks?.updateLocked const canDeleteRow = userPermissions.canEdit && !locks?.deleteLocked const canMutateSchema = userPermissions.canEdit && !locks?.schemaLocked + // Dropping or retyping a column rewrites every row's data, so `assertColumnDestructive` + // requires the delete lock clear too — mirror that here or the affordance + // stays live on an append-only table and only fails on click. + const canDestroyColumn = canMutateSchema && !locks?.deleteLocked // Manual grid entry is "add an empty row, then type into its cells" — the // typing is an update. So a *useful* manual add needs BOTH insert and update // unlocked; on an append-only table (update locked) it would leave a blank @@ -1245,6 +1249,11 @@ export function TableGrid({ void handleAppendRow() }, [handleAppendRow]) + /** Stable stand-in for `handleDeleteColumn` when a lock forbids the drop. */ + const handleBlockedDeleteColumn = useCallback(() => { + onBlockedActionRef.current('delete-column') + }, []) + const handleRowContextMenu = useCallback( (e: React.MouseEvent, row: TableRowType) => { setEditingCell(null) @@ -3803,9 +3812,9 @@ export function TableGrid({ onInsertRight={ canMutateSchema ? handleInsertColumnRight : undefined } - onDeleteColumn={canMutateSchema ? handleDeleteColumn : undefined} + onDeleteColumn={canDestroyColumn ? handleDeleteColumn : undefined} onDeleteGroup={ - canMutateSchema ? handleDeleteWorkflowGroup : undefined + canDestroyColumn ? handleDeleteWorkflowGroup : undefined } onViewWorkflow={ workflowGroupById.get(g.groupId)?.type === 'enrichment' @@ -3887,7 +3896,9 @@ export function TableGrid({ onColumnSelect={handleColumnSelect} onInsertLeft={handleInsertColumnLeft} onInsertRight={handleInsertColumnRight} - onDeleteColumn={handleDeleteColumn} + onDeleteColumn={ + canDestroyColumn ? handleDeleteColumn : handleBlockedDeleteColumn + } onResizeStart={handleColumnResizeStart} onResize={handleColumnResize} onResizeEnd={handleColumnResizeEnd} diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-locked-modal/table-locked-modal.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-locked-modal/table-locked-modal.tsx index 013841a33ab..9d20bc32ecf 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-locked-modal/table-locked-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-locked-modal/table-locked-modal.tsx @@ -15,7 +15,7 @@ import { lockedNouns } from '@/app/workspace/[workspaceId]/tables/[tableId]/lock * Why the locked-table modal opened. `'status'` is the informational case (the * header lock chip); the rest are actions the user just tried and couldn't do. */ -export type BlockedTableAction = 'add-row' | 'add-column' | 'edit-cell' | 'status' +export type BlockedTableAction = 'add-row' | 'add-column' | 'delete-column' | 'edit-cell' | 'status' /** * Copy for the action the user attempted. Explains what is blocked and — for @@ -40,6 +40,18 @@ function describe(action: BlockedTableAction, locks: TableLocks): { title: strin title: 'Changing columns is locked', text: 'Columns cannot be added, renamed, retyped, or removed until an admin unlocks it.', } + case 'delete-column': + // Reachable with the schema lock off but the delete lock on — removing a + // column clears its value from every row, so it needs both. + return locks.schemaLocked + ? { + title: 'Changing columns is locked', + text: 'Columns cannot be added, renamed, retyped, or removed until an admin unlocks it.', + } + : { + title: 'Deleting columns is locked', + text: 'Removing a column also deletes its value from every row, so it is blocked while this table is delete-locked.', + } case 'edit-cell': return { title: 'Editing rows is locked', diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx index a3ee41d537b..24eabe598a9 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx @@ -8,6 +8,7 @@ import { useParams, useRouter } from 'next/navigation' import { useQueryStates } from 'nuqs' import { usePostHog } from 'posthog-js/react' import type { RunLimit, RunMode } from '@/lib/api/contracts/tables' +import { getEnv, isTruthy } from '@/lib/core/config/env' import { captureEvent } from '@/lib/posthog/client' import type { ColumnDefinition, @@ -74,6 +75,14 @@ import { generateColumnName } from './utils' const logger = createLogger('Table') +/** + * Client mirror of the server's `table-locks` flag. Hides the settings entry + * point when locks can't be set, so the panel never opens onto a Save that + * 403s. An already-locked table still shows its state regardless, so the + * gating stays legible if the flag is turned off after locks were applied. + */ +const tableLocksEnabled = isTruthy(getEnv('NEXT_PUBLIC_TABLE_LOCKS')) + interface TableProps { /** When set, the table renders without its page header / breadcrumbs / page-level * options bar. Used by the mothership chat panel to embed a table inline. */ @@ -556,7 +565,7 @@ export function Table({ icon: Pencil, onClick: handleStartTableRename, }, - ...(userPermissions.canAdmin + ...(userPermissions.canAdmin && tableLocksEnabled ? [ { label: 'Lock settings', @@ -593,11 +602,12 @@ export function Table({ const headerActions = useMemo(() => { if (!tableData) return undefined const anyLocked = lockedNouns(tableData.locks).length > 0 - // Name the mode when locked so the state is legible on open; admins always - // get the entry point, even on an unlocked table. + // Name the mode when locked so the state is legible on open; admins get the + // entry point on an unlocked table only where the feature is enabled — + // otherwise the panel opens and Save 403s against the server-side gate. const lockLabel = anyLocked ? describeLocks(tableData.locks).name : 'Lock settings' return [ - ...(anyLocked || userPermissions.canAdmin + ...(anyLocked || (userPermissions.canAdmin && tableLocksEnabled) ? [ { label: lockLabel, @@ -1028,7 +1038,9 @@ export function Table({ setBlockedAction(null)} onOpenLockSettings={() => setShowLockSettings(true)} /> diff --git a/apps/sim/lib/core/config/env.ts b/apps/sim/lib/core/config/env.ts index 76b87384171..5c135e7d12e 100644 --- a/apps/sim/lib/core/config/env.ts +++ b/apps/sim/lib/core/config/env.ts @@ -572,6 +572,7 @@ export const env = createEnv({ NEXT_PUBLIC_SSO_ENABLED: z.boolean().optional(), // Enable SSO login UI components NEXT_PUBLIC_ACCESS_CONTROL_ENABLED: z.boolean().optional(), // Enable access control (permission groups) on self-hosted NEXT_PUBLIC_CUSTOM_BLOCKS_ENABLED: z.boolean().optional(), // Enable custom blocks (deploy-as-block) settings on self-hosted + NEXT_PUBLIC_TABLE_LOCKS: z.boolean().optional(), // Surface the per-table mutation-lock settings UI (client mirror of the server-side TABLE_LOCKS gate) NEXT_PUBLIC_WHITELABELING_ENABLED: z.boolean().optional(), // Enable whitelabeling on self-hosted (bypasses hosted requirements) NEXT_PUBLIC_AUDIT_LOGS_ENABLED: z.boolean().optional(), // Enable audit logs on self-hosted (bypasses hosted requirements) NEXT_PUBLIC_DATA_RETENTION_ENABLED: z.boolean().optional(), // Enable data retention settings on self-hosted (bypasses hosted requirements) @@ -613,6 +614,7 @@ export const env = createEnv({ NEXT_PUBLIC_SSO_ENABLED: process.env.NEXT_PUBLIC_SSO_ENABLED, NEXT_PUBLIC_ACCESS_CONTROL_ENABLED: process.env.NEXT_PUBLIC_ACCESS_CONTROL_ENABLED, NEXT_PUBLIC_CUSTOM_BLOCKS_ENABLED: process.env.NEXT_PUBLIC_CUSTOM_BLOCKS_ENABLED, + NEXT_PUBLIC_TABLE_LOCKS: process.env.NEXT_PUBLIC_TABLE_LOCKS, NEXT_PUBLIC_WHITELABELING_ENABLED: process.env.NEXT_PUBLIC_WHITELABELING_ENABLED, NEXT_PUBLIC_AUDIT_LOGS_ENABLED: process.env.NEXT_PUBLIC_AUDIT_LOGS_ENABLED, NEXT_PUBLIC_DATA_RETENTION_ENABLED: process.env.NEXT_PUBLIC_DATA_RETENTION_ENABLED, diff --git a/apps/sim/lib/table/delete-runner.test.ts b/apps/sim/lib/table/delete-runner.test.ts index 575e04b3c58..18c6c92f02e 100644 --- a/apps/sim/lib/table/delete-runner.test.ts +++ b/apps/sim/lib/table/delete-runner.test.ts @@ -11,6 +11,7 @@ const { mockUpdateJobProgress, mockMarkJobReady, mockMarkJobFailed, + mockMarkJobCanceled, mockAppendTableEvent, mockBuildFilterClause, } = vi.hoisted(() => ({ @@ -21,6 +22,7 @@ const { mockUpdateJobProgress: vi.fn(), mockMarkJobReady: vi.fn(), mockMarkJobFailed: vi.fn(), + mockMarkJobCanceled: vi.fn(), mockAppendTableEvent: vi.fn(), mockBuildFilterClause: vi.fn(), })) @@ -33,6 +35,7 @@ vi.mock('@/lib/table/jobs/service', () => ({ updateJobProgress: mockUpdateJobProgress, markJobReady: mockMarkJobReady, markJobFailed: mockMarkJobFailed, + markJobCanceled: mockMarkJobCanceled, })) vi.mock('@/lib/table/rows/ordering', () => ({ selectRowIdPage: mockSelectRowIdPage, @@ -47,7 +50,13 @@ vi.mock('@/lib/table/constants', () => ({ import { markTableDeleteFailed, runTableDelete } from '@/lib/table/delete-runner' -const table = { id: 'tbl_1', workspaceId: 'ws_1', schema: { columns: [] } } +const UNLOCKED = { + schemaLocked: false, + insertLocked: false, + updateLocked: false, + deleteLocked: false, +} +const table = { id: 'tbl_1', workspaceId: 'ws_1', schema: { columns: [] }, locks: UNLOCKED } const cutoff = new Date('2026-06-05T00:00:00Z') function basePayload(overrides = {}) { @@ -66,6 +75,22 @@ describe('runTableDelete', () => { mockBuildFilterClause.mockReturnValue({}) }) + it('cancels without deleting when the table was delete-locked before the run started', async () => { + // The lock is asserted at enqueue, but a queued or retried job can start + // after an admin locks the table — nothing is written yet, so honor it. + mockGetTableById.mockResolvedValue({ ...table, locks: { ...UNLOCKED, deleteLocked: true } }) + mockSelectRowIdPage.mockResolvedValue(['a', 'b']) + + await expect(runTableDelete(basePayload())).resolves.toBeUndefined() + + expect(mockDeletePageByIds).not.toHaveBeenCalled() + expect(mockMarkJobCanceled).toHaveBeenCalledWith('tbl_1', 'job_1') + expect(mockMarkJobReady).not.toHaveBeenCalled() + expect(mockAppendTableEvent).toHaveBeenCalledWith( + expect.objectContaining({ kind: 'job', type: 'delete', status: 'canceled' }) + ) + }) + it('deletes every matching page then marks the job ready', async () => { mockSelectRowIdPage .mockResolvedValueOnce(['a', 'b']) diff --git a/apps/sim/lib/table/delete-runner.ts b/apps/sim/lib/table/delete-runner.ts index bdc47a77d1c..6700525730a 100644 --- a/apps/sim/lib/table/delete-runner.ts +++ b/apps/sim/lib/table/delete-runner.ts @@ -7,6 +7,7 @@ import { TABLE_LIMITS, USER_TABLE_ROWS_SQL_NAME } from '@/lib/table/constants' import { appendTableEvent } from '@/lib/table/events' import { getJobProgress, + markJobCanceled, markJobFailed, markJobReady, updateJobProgress, @@ -67,11 +68,23 @@ export async function runTableDelete(payload: TableDeletePayload): Promise const table = await getTableById(tableId, { includeArchived: true }) if (!table) throw new Error(`Delete target table ${tableId} not found`) - // The delete lock is asserted at the enqueue site (delete-async route), which - // is what gates *starting* the job. A job already admitted runs to completion - // — committed pages are never rolled back, and an admin cancels it to stop — - // so the runner is a trusted continuation and does not re-assert (which would - // also retry-storm this maxAttempts:3 task on a mid-job lock). + // A lock set between admission and this worker actually starting (queue + // delay, or a retry re-entering here) means nothing has been deleted yet — + // honor it and stop. Returning rather than throwing releases the job slot + // without burning this task's remaining `maxAttempts` on a deterministic + // failure. Past this point the run continues even if a lock lands + // mid-flight: committed pages are never rolled back, so aborting would + // leave the same half-done state as a cancel that nobody asked for — an + // admin stops it explicitly via `POST /api/table/[tableId]/job/cancel`. + if (table.locks?.deleteLocked) { + logger.info(`[${requestId}] Delete job stopped — table was delete-locked before it started`, { + tableId, + jobId, + }) + await markJobCanceled(tableId, jobId) + void appendTableEvent({ kind: 'job', type: 'delete', tableId, jobId, status: 'canceled' }) + return + } const deleteProof = unsafeMutationProof<'delete'>() const filterClause = filter diff --git a/apps/sim/lib/table/update-runner.test.ts b/apps/sim/lib/table/update-runner.test.ts index a1dc26248e1..ad1e3af3407 100644 --- a/apps/sim/lib/table/update-runner.test.ts +++ b/apps/sim/lib/table/update-runner.test.ts @@ -11,6 +11,7 @@ const { mockUpdateJobProgress, mockMarkJobReady, mockMarkJobFailed, + mockMarkJobCanceled, mockAppendTableEvent, mockBuildFilterClause, mockValidateRowSize, @@ -24,6 +25,7 @@ const { mockUpdateJobProgress: vi.fn(), mockMarkJobReady: vi.fn(), mockMarkJobFailed: vi.fn(), + mockMarkJobCanceled: vi.fn(), mockAppendTableEvent: vi.fn(), mockBuildFilterClause: vi.fn(), mockValidateRowSize: vi.fn(), @@ -37,6 +39,7 @@ vi.mock('@/lib/table/jobs/service', () => ({ updateJobProgress: mockUpdateJobProgress, markJobReady: mockMarkJobReady, markJobFailed: mockMarkJobFailed, + markJobCanceled: mockMarkJobCanceled, })) vi.mock('@/lib/table/rows/ordering', () => ({ selectRowDataPage: mockSelectRowDataPage, @@ -56,7 +59,13 @@ vi.mock('@/lib/table/constants', () => ({ import { markTableUpdateFailed, runTableUpdate } from '@/lib/table/update-runner' -const table = { id: 'tbl_1', workspaceId: 'ws_1', schema: { columns: [] } } +const UNLOCKED = { + schemaLocked: false, + insertLocked: false, + updateLocked: false, + deleteLocked: false, +} +const table = { id: 'tbl_1', workspaceId: 'ws_1', schema: { columns: [] }, locks: UNLOCKED } const cutoff = new Date('2026-06-05T00:00:00Z') function basePayload(overrides = {}) { @@ -86,6 +95,22 @@ describe('runTableUpdate', () => { mockCoerceRowToSchema.mockReturnValue({ valid: true, errors: [] }) }) + it('cancels without updating when the table was update-locked before the run started', async () => { + // The lock is asserted at enqueue, but a queued or retried job can start + // after an admin locks the table — nothing is written yet, so honor it. + mockGetTableById.mockResolvedValue({ ...table, locks: { ...UNLOCKED, updateLocked: true } }) + mockSelectRowDataPage.mockResolvedValue([row('a')]) + + await expect(runTableUpdate(basePayload())).resolves.toBeUndefined() + + expect(mockUpdatePageByIds).not.toHaveBeenCalled() + expect(mockMarkJobCanceled).toHaveBeenCalledWith('tbl_1', 'job_1') + expect(mockMarkJobReady).not.toHaveBeenCalled() + expect(mockAppendTableEvent).toHaveBeenCalledWith( + expect.objectContaining({ kind: 'job', type: 'update', status: 'canceled' }) + ) + }) + it('updates every matching page then marks the job ready', async () => { mockSelectRowDataPage .mockResolvedValueOnce([row('a'), row('b')]) diff --git a/apps/sim/lib/table/update-runner.ts b/apps/sim/lib/table/update-runner.ts index 0e5c8ab0dc3..664bb0bd7ed 100644 --- a/apps/sim/lib/table/update-runner.ts +++ b/apps/sim/lib/table/update-runner.ts @@ -7,6 +7,7 @@ import { TABLE_LIMITS, USER_TABLE_ROWS_SQL_NAME } from '@/lib/table/constants' import { appendTableEvent } from '@/lib/table/events' import { getJobProgress, + markJobCanceled, markJobFailed, markJobReady, updateJobProgress, @@ -68,9 +69,19 @@ export async function runTableUpdate(payload: TableUpdatePayload): Promise const table = await getTableById(tableId, { includeArchived: true }) if (!table) throw new Error(`Update target table ${tableId} not found`) - // The update lock is asserted at the enqueue site; an admitted job runs to - // completion (committed pages stay; admin cancels to stop). Trusted - // continuation — see delete-runner for the full rationale. + // A lock set between admission and this worker starting (queue delay, or a + // retry re-entering here) means nothing has been written yet — honor it and + // stop. See `delete-runner.ts` for the full rationale, including why a + // mid-flight lock does NOT abort a run that has already committed pages. + if (table.locks?.updateLocked) { + logger.info(`[${requestId}] Update job stopped — table was update-locked before it started`, { + tableId, + jobId, + }) + await markJobCanceled(tableId, jobId) + void appendTableEvent({ kind: 'job', type: 'update', tableId, jobId, status: 'canceled' }) + return + } const updateProof = unsafeMutationProof<'update'>() const filterClause = buildFilterClause(filter, USER_TABLE_ROWS_SQL_NAME, table.schema.columns) diff --git a/packages/db/migrations/0270_table_mutation_locks.sql b/packages/db/migrations/0270_table_mutation_locks.sql index d79f493d042..24703cc1909 100644 --- a/packages/db/migrations/0270_table_mutation_locks.sql +++ b/packages/db/migrations/0270_table_mutation_locks.sql @@ -1,4 +1,4 @@ ALTER TABLE "user_table_definitions" ADD COLUMN "schema_locked" boolean DEFAULT false NOT NULL;--> statement-breakpoint ALTER TABLE "user_table_definitions" ADD COLUMN "insert_locked" boolean DEFAULT false NOT NULL;--> statement-breakpoint ALTER TABLE "user_table_definitions" ADD COLUMN "update_locked" boolean DEFAULT false NOT NULL;--> statement-breakpoint -ALTER TABLE "user_table_definitions" ADD COLUMN "delete_locked" boolean DEFAULT false NOT NULL; +ALTER TABLE "user_table_definitions" ADD COLUMN "delete_locked" boolean DEFAULT false NOT NULL; \ No newline at end of file diff --git a/packages/db/migrations/meta/0270_snapshot.json b/packages/db/migrations/meta/0270_snapshot.json new file mode 100644 index 00000000000..d092e8d7be7 --- /dev/null +++ b/packages/db/migrations/meta/0270_snapshot.json @@ -0,0 +1,17720 @@ +{ + "id": "37805afc-8716-4dd6-be76-5f3a26396697", + "prevId": "dea711e3-b8c7-4279-bb8a-353bdaffdfe8", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.academy_certificate": { + "name": "academy_certificate", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "course_id": { + "name": "course_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "academy_cert_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "issued_at": { + "name": "issued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "certificate_number": { + "name": "certificate_number", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "academy_certificate_user_id_idx": { + "name": "academy_certificate_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_course_id_idx": { + "name": "academy_certificate_course_id_idx", + "columns": [ + { + "expression": "course_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_user_course_unique": { + "name": "academy_certificate_user_course_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "course_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_number_idx": { + "name": "academy_certificate_number_idx", + "columns": [ + { + "expression": "certificate_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_status_idx": { + "name": "academy_certificate_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "academy_certificate_user_id_user_id_fk": { + "name": "academy_certificate_user_id_user_id_fk", + "tableFrom": "academy_certificate", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "academy_certificate_certificate_number_unique": { + "name": "academy_certificate_certificate_number_unique", + "nullsNotDistinct": false, + "columns": ["certificate_number"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_user_id_idx": { + "name": "account_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_account_on_account_id_provider_id": { + "name": "idx_account_on_account_id_provider_id", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_key": { + "name": "api_key", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'personal'" + }, + "last_used": { + "name": "last_used", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "api_key_workspace_type_idx": { + "name": "api_key_workspace_type_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_key_user_type_idx": { + "name": "api_key_user_type_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_key_key_hash_idx": { + "name": "api_key_key_hash_idx", + "columns": [ + { + "expression": "key_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "api_key_user_id_user_id_fk": { + "name": "api_key_user_id_user_id_fk", + "tableFrom": "api_key", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "api_key_workspace_id_workspace_id_fk": { + "name": "api_key_workspace_id_workspace_id_fk", + "tableFrom": "api_key", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "api_key_created_by_user_id_fk": { + "name": "api_key_created_by_user_id_fk", + "tableFrom": "api_key", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "api_key_key_unique": { + "name": "api_key_key_unique", + "nullsNotDistinct": false, + "columns": ["key"] + } + }, + "policies": {}, + "checkConstraints": { + "workspace_type_check": { + "name": "workspace_type_check", + "value": "(type = 'workspace' AND workspace_id IS NOT NULL) OR (type = 'personal' AND workspace_id IS NULL)" + } + }, + "isRLSEnabled": false + }, + "public.async_jobs": { + "name": "async_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "run_at": { + "name": "run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "max_attempts": { + "name": "max_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 3 + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "output": { + "name": "output", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "async_jobs_status_started_at_idx": { + "name": "async_jobs_status_started_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_status_completed_at_idx": { + "name": "async_jobs_status_completed_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "completed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_schedule_pending_run_at_idx": { + "name": "async_jobs_schedule_pending_run_at_idx", + "columns": [ + { + "expression": "run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"async_jobs\".\"type\" = 'schedule-execution' AND \"async_jobs\".\"status\" = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_schedule_processing_started_at_idx": { + "name": "async_jobs_schedule_processing_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"async_jobs\".\"type\" = 'schedule-execution' AND \"async_jobs\".\"status\" = 'processing'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_log": { + "name": "audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_name": { + "name": "actor_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_email": { + "name": "actor_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resource_name": { + "name": "resource_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_log_workspace_created_idx": { + "name": "audit_log_workspace_created_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_workspace_created_at_id_idx": { + "name": "audit_log_workspace_created_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"created_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_actor_created_idx": { + "name": "audit_log_actor_created_idx", + "columns": [ + { + "expression": "actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_resource_idx": { + "name": "audit_log_resource_idx", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_action_idx": { + "name": "audit_log_action_idx", + "columns": [ + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_log_workspace_id_workspace_id_fk": { + "name": "audit_log_workspace_id_workspace_id_fk", + "tableFrom": "audit_log", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "audit_log_actor_id_user_id_fk": { + "name": "audit_log_actor_id_user_id_fk", + "tableFrom": "audit_log", + "tableTo": "user", + "columnsFrom": ["actor_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.background_work_status": { + "name": "background_work_status", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "background_work_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "background_work_status_value", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "background_work_status_workspace_status_idx": { + "name": "background_work_status_workspace_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "background_work_status_workflow_status_idx": { + "name": "background_work_status_workflow_status_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "background_work_status_meta_child_ws_idx": { + "name": "background_work_status_meta_child_ws_idx", + "columns": [ + { + "expression": "(\"metadata\" ->> 'childWorkspaceId')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "background_work_status_meta_other_ws_idx": { + "name": "background_work_status_meta_other_ws_idx", + "columns": [ + { + "expression": "(\"metadata\" ->> 'otherWorkspaceId')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "background_work_status_workspace_id_workspace_id_fk": { + "name": "background_work_status_workspace_id_workspace_id_fk", + "tableFrom": "background_work_status", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "background_work_status_workflow_id_workflow_id_fk": { + "name": "background_work_status_workflow_id_workflow_id_fk", + "tableFrom": "background_work_status", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat": { + "name": "chat", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "customizations": { + "name": "customizations", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowed_emails": { + "name": "allowed_emails", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "output_configs": { + "name": "output_configs", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "include_thinking": { + "name": "include_thinking", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "include_tool_calls": { + "name": "include_tool_calls", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "identifier_idx": { + "name": "identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"chat\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_archived_at_partial_idx": { + "name": "chat_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"chat\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_chat_on_workflow_id_archived_at": { + "name": "idx_chat_on_workflow_id_archived_at", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_workflow_id_workflow_id_fk": { + "name": "chat_workflow_id_workflow_id_fk", + "tableFrom": "chat", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chat_user_id_user_id_fk": { + "name": "chat_user_id_user_id_fk", + "tableFrom": "chat", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_async_tool_calls": { + "name": "copilot_async_tool_calls", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "checkpoint_id": { + "name": "checkpoint_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "tool_call_id": { + "name": "tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "args": { + "name": "args", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "status": { + "name": "status", + "type": "copilot_async_tool_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "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 + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_chats_user_id_idx": { + "name": "copilot_chats_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_workflow_id_idx": { + "name": "copilot_chats_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_user_workflow_idx": { + "name": "copilot_chats_user_workflow_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_user_workspace_idx": { + "name": "copilot_chats_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_created_at_idx": { + "name": "copilot_chats_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_updated_at_idx": { + "name": "copilot_chats_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_workspace_created_at_id_idx": { + "name": "copilot_chats_workspace_created_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"created_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_user_workspace_deleted_partial_idx": { + "name": "copilot_chats_user_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_chats\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_chats_user_id_user_id_fk": { + "name": "copilot_chats_user_id_user_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_chats_workflow_id_workflow_id_fk": { + "name": "copilot_chats_workflow_id_workflow_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_chats_workspace_id_workspace_id_fk": { + "name": "copilot_chats_workspace_id_workspace_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_feedback": { + "name": "copilot_feedback", + "schema": "", + "columns": { + "feedback_id": { + "name": "feedback_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_query": { + "name": "user_query", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_response": { + "name": "agent_response", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_positive": { + "name": "is_positive", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "feedback": { + "name": "feedback", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_yaml": { + "name": "workflow_yaml", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_feedback_user_id_idx": { + "name": "copilot_feedback_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_chat_id_idx": { + "name": "copilot_feedback_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_user_chat_idx": { + "name": "copilot_feedback_user_chat_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_is_positive_idx": { + "name": "copilot_feedback_is_positive_idx", + "columns": [ + { + "expression": "is_positive", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_created_at_idx": { + "name": "copilot_feedback_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_feedback_user_id_user_id_fk": { + "name": "copilot_feedback_user_id_user_id_fk", + "tableFrom": "copilot_feedback", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_feedback_chat_id_copilot_chats_id_fk": { + "name": "copilot_feedback_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_feedback", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_messages": { + "name": "copilot_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "stream_id": { + "name": "stream_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parent_message_id": { + "name": "parent_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tokens_in": { + "name": "tokens_in", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "tokens_out": { + "name": "tokens_out", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_messages_chat_message_unique": { + "name": "copilot_messages_chat_message_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_chat_created_at_idx": { + "name": "copilot_messages_chat_created_at_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_chat_seq_idx": { + "name": "copilot_messages_chat_seq_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "seq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_chat_stream_idx": { + "name": "copilot_messages_chat_stream_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "stream_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"stream_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_user_created_at_idx": { + "name": "copilot_messages_user_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"role\" = 'user' AND \"copilot_messages\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_messages_chat_id_copilot_chats_id_fk": { + "name": "copilot_messages_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_messages", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_run_checkpoints": { + "name": "copilot_run_checkpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "pending_tool_call_id": { + "name": "pending_tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conversation_snapshot": { + "name": "conversation_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "agent_state": { + "name": "agent_state", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "provider_request": { + "name": "provider_request", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_run_checkpoints_run_id_idx": { + "name": "copilot_run_checkpoints_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_run_checkpoints_pending_tool_call_id_idx": { + "name": "copilot_run_checkpoints_pending_tool_call_id_idx", + "columns": [ + { + "expression": "pending_tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_run_checkpoints_run_pending_tool_unique": { + "name": "copilot_run_checkpoints_run_pending_tool_unique", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pending_tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_run_checkpoints_run_id_copilot_runs_id_fk": { + "name": "copilot_run_checkpoints_run_id_copilot_runs_id_fk", + "tableFrom": "copilot_run_checkpoints", + "tableTo": "copilot_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_runs": { + "name": "copilot_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_run_id": { + "name": "parent_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stream_id": { + "name": "stream_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent": { + "name": "agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "copilot_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "request_context": { + "name": "request_context", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "copilot_runs_execution_id_idx": { + "name": "copilot_runs_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_parent_run_id_idx": { + "name": "copilot_runs_parent_run_id_idx", + "columns": [ + { + "expression": "parent_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_chat_id_idx": { + "name": "copilot_runs_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_user_id_idx": { + "name": "copilot_runs_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_workflow_id_idx": { + "name": "copilot_runs_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_workspace_id_idx": { + "name": "copilot_runs_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_status_idx": { + "name": "copilot_runs_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_chat_execution_idx": { + "name": "copilot_runs_chat_execution_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_execution_started_at_idx": { + "name": "copilot_runs_execution_started_at_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_workspace_completed_at_id_idx": { + "name": "copilot_runs_workspace_completed_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"completed_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_stream_id_unique": { + "name": "copilot_runs_stream_id_unique", + "columns": [ + { + "expression": "stream_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_runs_chat_id_copilot_chats_id_fk": { + "name": "copilot_runs_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_user_id_user_id_fk": { + "name": "copilot_runs_user_id_user_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_workflow_id_workflow_id_fk": { + "name": "copilot_runs_workflow_id_workflow_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_workspace_id_workspace_id_fk": { + "name": "copilot_runs_workspace_id_workspace_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_workflow_read_hashes": { + "name": "copilot_workflow_read_hashes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_workflow_read_hashes_chat_id_idx": { + "name": "copilot_workflow_read_hashes_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_workflow_read_hashes_workflow_id_idx": { + "name": "copilot_workflow_read_hashes_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_workflow_read_hashes_chat_workflow_unique": { + "name": "copilot_workflow_read_hashes_chat_workflow_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_workflow_read_hashes_chat_id_copilot_chats_id_fk": { + "name": "copilot_workflow_read_hashes_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_workflow_read_hashes", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_workflow_read_hashes_workflow_id_workflow_id_fk": { + "name": "copilot_workflow_read_hashes_workflow_id_workflow_id_fk", + "tableFrom": "copilot_workflow_read_hashes", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credential": { + "name": "credential", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "credential_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env_key": { + "name": "env_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env_owner_user_id": { + "name": "env_owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_service_account_key": { + "name": "encrypted_service_account_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_workspace_id_idx": { + "name": "credential_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_type_idx": { + "name": "credential_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_provider_id_idx": { + "name": "credential_provider_id_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_account_id_idx": { + "name": "credential_account_id_idx", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_env_owner_user_id_idx": { + "name": "credential_env_owner_user_id_idx", + "columns": [ + { + "expression": "env_owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_account_unique": { + "name": "credential_workspace_account_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "account_id IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_env_unique": { + "name": "credential_workspace_env_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "type = 'env_workspace'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_personal_env_unique": { + "name": "credential_workspace_personal_env_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "type = 'env_personal'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_workspace_id_workspace_id_fk": { + "name": "credential_workspace_id_workspace_id_fk", + "tableFrom": "credential", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_account_id_account_id_fk": { + "name": "credential_account_id_account_id_fk", + "tableFrom": "credential", + "tableTo": "account", + "columnsFrom": ["account_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_env_owner_user_id_user_id_fk": { + "name": "credential_env_owner_user_id_user_id_fk", + "tableFrom": "credential", + "tableTo": "user", + "columnsFrom": ["env_owner_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_created_by_user_id_fk": { + "name": "credential_created_by_user_id_fk", + "tableFrom": "credential", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "credential_oauth_source_check": { + "name": "credential_oauth_source_check", + "value": "(type <> 'oauth') OR (account_id IS NOT NULL AND provider_id IS NOT NULL)" + }, + "credential_workspace_env_source_check": { + "name": "credential_workspace_env_source_check", + "value": "(type <> 'env_workspace') OR (env_key IS NOT NULL AND env_owner_user_id IS NULL)" + }, + "credential_personal_env_source_check": { + "name": "credential_personal_env_source_check", + "value": "(type <> 'env_personal') OR (env_key IS NOT NULL AND env_owner_user_id IS NOT NULL)" + } + }, + "isRLSEnabled": false + }, + "public.credential_member": { + "name": "credential_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "credential_member_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "status": { + "name": "status", + "type": "credential_member_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_member_user_id_idx": { + "name": "credential_member_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_role_idx": { + "name": "credential_member_role_idx", + "columns": [ + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_status_idx": { + "name": "credential_member_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_unique": { + "name": "credential_member_unique", + "columns": [ + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_member_credential_id_credential_id_fk": { + "name": "credential_member_credential_id_credential_id_fk", + "tableFrom": "credential_member", + "tableTo": "credential", + "columnsFrom": ["credential_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_member_user_id_user_id_fk": { + "name": "credential_member_user_id_user_id_fk", + "tableFrom": "credential_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_member_invited_by_user_id_fk": { + "name": "credential_member_invited_by_user_id_fk", + "tableFrom": "credential_member", + "tableTo": "user", + "columnsFrom": ["invited_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.custom_block": { + "name": "custom_block", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "icon_url": { + "name": "icon_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "inputs": { + "name": "inputs", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "outputs": { + "name": "outputs", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "custom_block_organization_id_idx": { + "name": "custom_block_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_block_workflow_id_idx": { + "name": "custom_block_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_block_organization_type_unique": { + "name": "custom_block_organization_type_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "custom_block_organization_id_organization_id_fk": { + "name": "custom_block_organization_id_organization_id_fk", + "tableFrom": "custom_block", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "custom_block_workflow_id_workflow_id_fk": { + "name": "custom_block_workflow_id_workflow_id_fk", + "tableFrom": "custom_block", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "custom_block_created_by_user_id_fk": { + "name": "custom_block_created_by_user_id_fk", + "tableFrom": "custom_block", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.custom_tools": { + "name": "custom_tools", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schema": { + "name": "schema", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "custom_tools_workspace_id_idx": { + "name": "custom_tools_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_tools_workspace_title_unique": { + "name": "custom_tools_workspace_title_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "title", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "custom_tools_workspace_id_workspace_id_fk": { + "name": "custom_tools_workspace_id_workspace_id_fk", + "tableFrom": "custom_tools", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "custom_tools_user_id_user_id_fk": { + "name": "custom_tools_user_id_user_id_fk", + "tableFrom": "custom_tools", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.data_drain_runs": { + "name": "data_drain_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "drain_id": { + "name": "drain_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "data_drain_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "trigger": { + "name": "trigger", + "type": "data_drain_run_trigger", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "rows_exported": { + "name": "rows_exported", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "bytes_written": { + "name": "bytes_written", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cursor_before": { + "name": "cursor_before", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cursor_after": { + "name": "cursor_after", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "locators": { + "name": "locators", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + } + }, + "indexes": { + "data_drain_runs_drain_started_idx": { + "name": "data_drain_runs_drain_started_idx", + "columns": [ + { + "expression": "drain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "data_drain_runs_drain_id_data_drains_id_fk": { + "name": "data_drain_runs_drain_id_data_drains_id_fk", + "tableFrom": "data_drain_runs", + "tableTo": "data_drains", + "columnsFrom": ["drain_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.data_drains": { + "name": "data_drains", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "data_drain_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "destination_type": { + "name": "destination_type", + "type": "data_drain_destination", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "destination_config": { + "name": "destination_config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "destination_credentials": { + "name": "destination_credentials", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schedule_cadence": { + "name": "schedule_cadence", + "type": "data_drain_cadence", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "cursor": { + "name": "cursor", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_success_at": { + "name": "last_success_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "data_drains_org_idx": { + "name": "data_drains_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "data_drains_due_idx": { + "name": "data_drains_due_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "data_drains_org_name_unique": { + "name": "data_drains_org_name_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "data_drains_organization_id_organization_id_fk": { + "name": "data_drains_organization_id_organization_id_fk", + "tableFrom": "data_drains", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "data_drains_created_by_user_id_fk": { + "name": "data_drains_created_by_user_id_fk", + "tableFrom": "data_drains", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.docs_embeddings": { + "name": "docs_embeddings", + "schema": "", + "columns": { + "chunk_id": { + "name": "chunk_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chunk_text": { + "name": "chunk_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_document": { + "name": "source_document", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_link": { + "name": "source_link", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "header_text": { + "name": "header_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "header_level": { + "name": "header_level", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": true + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "chunk_text_tsv": { + "name": "chunk_text_tsv", + "type": "tsvector", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "to_tsvector('english', \"docs_embeddings\".\"chunk_text\")", + "type": "stored" + } + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "docs_emb_source_document_idx": { + "name": "docs_emb_source_document_idx", + "columns": [ + { + "expression": "source_document", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_header_level_idx": { + "name": "docs_emb_header_level_idx", + "columns": [ + { + "expression": "header_level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_source_header_idx": { + "name": "docs_emb_source_header_idx", + "columns": [ + { + "expression": "source_document", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "header_level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_model_idx": { + "name": "docs_emb_model_idx", + "columns": [ + { + "expression": "embedding_model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_created_at_idx": { + "name": "docs_emb_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_embedding_vector_hnsw_idx": { + "name": "docs_embedding_vector_hnsw_idx", + "columns": [ + { + "expression": "embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "docs_emb_metadata_gin_idx": { + "name": "docs_emb_metadata_gin_idx", + "columns": [ + { + "expression": "metadata", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "docs_emb_chunk_text_fts_idx": { + "name": "docs_emb_chunk_text_fts_idx", + "columns": [ + { + "expression": "chunk_text_tsv", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "docs_embedding_not_null_check": { + "name": "docs_embedding_not_null_check", + "value": "\"embedding\" IS NOT NULL" + }, + "docs_header_level_check": { + "name": "docs_header_level_check", + "value": "\"header_level\" >= 1 AND \"header_level\" <= 6" + } + }, + "isRLSEnabled": false + }, + "public.document": { + "name": "document", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_url": { + "name": "file_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "file_size": { + "name": "file_size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chunk_count": { + "name": "chunk_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "character_count": { + "name": "character_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "processing_status": { + "name": "processing_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "processing_started_at": { + "name": "processing_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "processing_completed_at": { + "name": "processing_completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "processing_error": { + "name": "processing_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "user_excluded": { + "name": "user_excluded", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "tag1": { + "name": "tag1", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag2": { + "name": "tag2", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag3": { + "name": "tag3", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag4": { + "name": "tag4", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag5": { + "name": "tag5", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag6": { + "name": "tag6", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag7": { + "name": "tag7", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "number1": { + "name": "number1", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number2": { + "name": "number2", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number3": { + "name": "number3", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number4": { + "name": "number4", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number5": { + "name": "number5", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "date1": { + "name": "date1", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "date2": { + "name": "date2", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "boolean1": { + "name": "boolean1", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean2": { + "name": "boolean2", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean3": { + "name": "boolean3", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "connector_id": { + "name": "connector_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_url": { + "name": "source_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "doc_kb_id_idx": { + "name": "doc_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_filename_idx": { + "name": "doc_filename_idx", + "columns": [ + { + "expression": "filename", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_processing_status_idx": { + "name": "doc_processing_status_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "processing_status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_connector_external_id_idx": { + "name": "doc_connector_external_id_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"document\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_connector_id_idx": { + "name": "doc_connector_id_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_storage_key_idx": { + "name": "doc_storage_key_idx", + "columns": [ + { + "expression": "storage_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"storage_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_archived_at_partial_idx": { + "name": "doc_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_deleted_at_partial_idx": { + "name": "doc_deleted_at_partial_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag1_idx": { + "name": "doc_tag1_idx", + "columns": [ + { + "expression": "tag1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag2_idx": { + "name": "doc_tag2_idx", + "columns": [ + { + "expression": "tag2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag3_idx": { + "name": "doc_tag3_idx", + "columns": [ + { + "expression": "tag3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag4_idx": { + "name": "doc_tag4_idx", + "columns": [ + { + "expression": "tag4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag5_idx": { + "name": "doc_tag5_idx", + "columns": [ + { + "expression": "tag5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag6_idx": { + "name": "doc_tag6_idx", + "columns": [ + { + "expression": "tag6", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag7_idx": { + "name": "doc_tag7_idx", + "columns": [ + { + "expression": "tag7", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number1_idx": { + "name": "doc_number1_idx", + "columns": [ + { + "expression": "number1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number2_idx": { + "name": "doc_number2_idx", + "columns": [ + { + "expression": "number2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number3_idx": { + "name": "doc_number3_idx", + "columns": [ + { + "expression": "number3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number4_idx": { + "name": "doc_number4_idx", + "columns": [ + { + "expression": "number4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number5_idx": { + "name": "doc_number5_idx", + "columns": [ + { + "expression": "number5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_date1_idx": { + "name": "doc_date1_idx", + "columns": [ + { + "expression": "date1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_date2_idx": { + "name": "doc_date2_idx", + "columns": [ + { + "expression": "date2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean1_idx": { + "name": "doc_boolean1_idx", + "columns": [ + { + "expression": "boolean1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean2_idx": { + "name": "doc_boolean2_idx", + "columns": [ + { + "expression": "boolean2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean3_idx": { + "name": "doc_boolean3_idx", + "columns": [ + { + "expression": "boolean3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "document_knowledge_base_id_knowledge_base_id_fk": { + "name": "document_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "document", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "document_connector_id_knowledge_connector_id_fk": { + "name": "document_connector_id_knowledge_connector_id_fk", + "tableFrom": "document", + "tableTo": "knowledge_connector", + "columnsFrom": ["connector_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "document_uploaded_by_user_id_fk": { + "name": "document_uploaded_by_user_id_fk", + "tableFrom": "document", + "tableTo": "user", + "columnsFrom": ["uploaded_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.embedding": { + "name": "embedding", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chunk_index": { + "name": "chunk_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "chunk_hash": { + "name": "chunk_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_length": { + "name": "content_length", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": false + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "start_offset": { + "name": "start_offset", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "end_offset": { + "name": "end_offset", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "tag1": { + "name": "tag1", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag2": { + "name": "tag2", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag3": { + "name": "tag3", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag4": { + "name": "tag4", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag5": { + "name": "tag5", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag6": { + "name": "tag6", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag7": { + "name": "tag7", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "number1": { + "name": "number1", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number2": { + "name": "number2", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number3": { + "name": "number3", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number4": { + "name": "number4", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number5": { + "name": "number5", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "date1": { + "name": "date1", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "date2": { + "name": "date2", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "boolean1": { + "name": "boolean1", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean2": { + "name": "boolean2", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean3": { + "name": "boolean3", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "content_tsv": { + "name": "content_tsv", + "type": "tsvector", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "to_tsvector('english', \"embedding\".\"content\")", + "type": "stored" + } + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "emb_kb_id_idx": { + "name": "emb_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_id_idx": { + "name": "emb_doc_id_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_chunk_idx": { + "name": "emb_doc_chunk_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chunk_index", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_model_idx": { + "name": "emb_kb_model_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "embedding_model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_enabled_idx": { + "name": "emb_kb_enabled_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_enabled_idx": { + "name": "emb_doc_enabled_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "embedding_vector_hnsw_idx": { + "name": "embedding_vector_hnsw_idx", + "columns": [ + { + "expression": "embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "emb_tag1_idx": { + "name": "emb_tag1_idx", + "columns": [ + { + "expression": "tag1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag2_idx": { + "name": "emb_tag2_idx", + "columns": [ + { + "expression": "tag2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag3_idx": { + "name": "emb_tag3_idx", + "columns": [ + { + "expression": "tag3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag4_idx": { + "name": "emb_tag4_idx", + "columns": [ + { + "expression": "tag4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag5_idx": { + "name": "emb_tag5_idx", + "columns": [ + { + "expression": "tag5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag6_idx": { + "name": "emb_tag6_idx", + "columns": [ + { + "expression": "tag6", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag7_idx": { + "name": "emb_tag7_idx", + "columns": [ + { + "expression": "tag7", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number1_idx": { + "name": "emb_number1_idx", + "columns": [ + { + "expression": "number1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number2_idx": { + "name": "emb_number2_idx", + "columns": [ + { + "expression": "number2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number3_idx": { + "name": "emb_number3_idx", + "columns": [ + { + "expression": "number3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number4_idx": { + "name": "emb_number4_idx", + "columns": [ + { + "expression": "number4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number5_idx": { + "name": "emb_number5_idx", + "columns": [ + { + "expression": "number5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_date1_idx": { + "name": "emb_date1_idx", + "columns": [ + { + "expression": "date1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_date2_idx": { + "name": "emb_date2_idx", + "columns": [ + { + "expression": "date2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_boolean1_idx": { + "name": "emb_boolean1_idx", + "columns": [ + { + "expression": "boolean1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_boolean2_idx": { + "name": "emb_boolean2_idx", + "columns": [ + { + "expression": "boolean2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_boolean3_idx": { + "name": "emb_boolean3_idx", + "columns": [ + { + "expression": "boolean3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_content_fts_idx": { + "name": "emb_content_fts_idx", + "columns": [ + { + "expression": "content_tsv", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "embedding_knowledge_base_id_knowledge_base_id_fk": { + "name": "embedding_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "embedding", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "embedding_document_id_document_id_fk": { + "name": "embedding_document_id_document_id_fk", + "tableFrom": "embedding", + "tableTo": "document", + "columnsFrom": ["document_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "embedding_not_null_check": { + "name": "embedding_not_null_check", + "value": "\"embedding\" IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.environment": { + "name": "environment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "environment_user_id_user_id_fk": { + "name": "environment_user_id_user_id_fk", + "tableFrom": "environment", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "environment_user_id_unique": { + "name": "environment_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.execution_large_value_dependencies": { + "name": "execution_large_value_dependencies", + "schema": "", + "columns": { + "parent_key": { + "name": "parent_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_key": { + "name": "child_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "execution_large_value_dependencies_workspace_parent_key_idx": { + "name": "execution_large_value_dependencies_workspace_parent_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_value_dependencies_workspace_child_key_idx": { + "name": "execution_large_value_dependencies_workspace_child_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "child_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "execution_large_value_dependencies_workspace_id_workspace_id_fk": { + "name": "execution_large_value_dependencies_workspace_id_workspace_id_fk", + "tableFrom": "execution_large_value_dependencies", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "execution_large_value_dependencies_parent_key_child_key_pk": { + "name": "execution_large_value_dependencies_parent_key_child_key_pk", + "columns": ["parent_key", "child_key"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.execution_large_value_references": { + "name": "execution_large_value_references", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "execution_large_value_reference_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "execution_large_value_references_workspace_execution_source_idx": { + "name": "execution_large_value_references_workspace_execution_source_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "execution_large_value_references_workspace_id_workspace_id_fk": { + "name": "execution_large_value_references_workspace_id_workspace_id_fk", + "tableFrom": "execution_large_value_references", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "execution_large_value_references_workflow_id_workflow_id_fk": { + "name": "execution_large_value_references_workflow_id_workflow_id_fk", + "tableFrom": "execution_large_value_references", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "execution_large_value_references_key_execution_id_source_pk": { + "name": "execution_large_value_references_key_execution_id_source_pk", + "columns": ["key", "execution_id", "source"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.execution_large_values": { + "name": "execution_large_values", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_execution_id": { + "name": "owner_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "execution_large_values_owner_execution_id_idx": { + "name": "execution_large_values_owner_execution_id_idx", + "columns": [ + { + "expression": "owner_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_values_cleanup_idx": { + "name": "execution_large_values_cleanup_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"execution_large_values\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_values_tombstone_cleanup_idx": { + "name": "execution_large_values_tombstone_cleanup_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"execution_large_values\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "execution_large_values_workspace_id_workspace_id_fk": { + "name": "execution_large_values_workspace_id_workspace_id_fk", + "tableFrom": "execution_large_values", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "execution_large_values_workflow_id_workflow_id_fk": { + "name": "execution_large_values_workflow_id_workflow_id_fk", + "tableFrom": "execution_large_values", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.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": "cascade", + "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 + }, + "session_policy_settings": { + "name": "session_policy_settings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "security_policy_version": { + "name": "security_policy_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "whitelabel_settings": { + "name": "whitelabel_settings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "data_retention_settings": { + "name": "data_retention_settings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "org_usage_limit": { + "name": "org_usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "limit_notifications": { + "name": "limit_notifications", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "departed_member_usage": { + "name": "departed_member_usage", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "credit_balance": { + "name": "credit_balance", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_member_usage_limit": { + "name": "organization_member_usage_limit", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "usage_limit": { + "name": "usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "set_by": { + "name": "set_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "org_member_usage_limit_org_user_unique": { + "name": "org_member_usage_limit_org_user_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "org_member_usage_limit_organization_id_idx": { + "name": "org_member_usage_limit_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "organization_member_usage_limit_organization_id_organization_id_fk": { + "name": "organization_member_usage_limit_organization_id_organization_id_fk", + "tableFrom": "organization_member_usage_limit", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_member_usage_limit_user_id_user_id_fk": { + "name": "organization_member_usage_limit_user_id_user_id_fk", + "tableFrom": "organization_member_usage_limit", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_member_usage_limit_set_by_user_id_fk": { + "name": "organization_member_usage_limit_set_by_user_id_fk", + "tableFrom": "organization_member_usage_limit", + "tableTo": "user", + "columnsFrom": ["set_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.outbox_event": { + "name": "outbox_event", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "max_attempts": { + "name": "max_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10 + }, + "available_at": { + "name": "available_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "locked_at": { + "name": "locked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "outbox_event_status_available_idx": { + "name": "outbox_event_status_available_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "available_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "outbox_event_locked_at_idx": { + "name": "outbox_event_locked_at_idx", + "columns": [ + { + "expression": "locked_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "outbox_event_type_created_idx": { + "name": "outbox_event_type_created_idx", + "columns": [ + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.paused_executions": { + "name": "paused_executions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_snapshot": { + "name": "execution_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "pause_points": { + "name": "pause_points", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "total_pause_count": { + "name": "total_pause_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "resumed_count": { + "name": "resumed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "automatic_resume_retry_count": { + "name": "automatic_resume_retry_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'paused'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "paused_at": { + "name": "paused_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "next_resume_at": { + "name": "next_resume_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "paused_executions_workflow_id_idx": { + "name": "paused_executions_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paused_executions_status_idx": { + "name": "paused_executions_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paused_executions_execution_id_unique": { + "name": "paused_executions_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paused_executions_next_resume_at_idx": { + "name": "paused_executions_next_resume_at_idx", + "columns": [ + { + "expression": "next_resume_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "status = 'paused' AND next_resume_at IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "paused_executions_workflow_id_workflow_id_fk": { + "name": "paused_executions_workflow_id_workflow_id_fk", + "tableFrom": "paused_executions", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pending_credential_draft": { + "name": "pending_credential_draft", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pending_draft_user_provider_ws": { + "name": "pending_draft_user_provider_ws", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pending_credential_draft_user_id_user_id_fk": { + "name": "pending_credential_draft_user_id_user_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pending_credential_draft_workspace_id_workspace_id_fk": { + "name": "pending_credential_draft_workspace_id_workspace_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pending_credential_draft_credential_id_credential_id_fk": { + "name": "pending_credential_draft_credential_id_credential_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "credential", + "columnsFrom": ["credential_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission_group": { + "name": "permission_group", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "permission_group_created_by_idx": { + "name": "permission_group_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_organization_name_unique": { + "name": "permission_group_organization_name_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_organization_default_unique": { + "name": "permission_group_organization_default_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "is_default = true", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_group_organization_id_organization_id_fk": { + "name": "permission_group_organization_id_organization_id_fk", + "tableFrom": "permission_group", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_created_by_user_id_fk": { + "name": "permission_group_created_by_user_id_fk", + "tableFrom": "permission_group", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission_group_member": { + "name": "permission_group_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "permission_group_id": { + "name": "permission_group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "assigned_by": { + "name": "assigned_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assigned_at": { + "name": "assigned_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permission_group_member_group_id_idx": { + "name": "permission_group_member_group_id_idx", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_member_group_user_unique": { + "name": "permission_group_member_group_user_unique", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_member_organization_user_idx": { + "name": "permission_group_member_organization_user_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_group_member_permission_group_id_permission_group_id_fk": { + "name": "permission_group_member_permission_group_id_permission_group_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "permission_group", + "columnsFrom": ["permission_group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_member_organization_id_organization_id_fk": { + "name": "permission_group_member_organization_id_organization_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_member_user_id_user_id_fk": { + "name": "permission_group_member_user_id_user_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_member_assigned_by_user_id_fk": { + "name": "permission_group_member_assigned_by_user_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "user", + "columnsFrom": ["assigned_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission_group_workspace": { + "name": "permission_group_workspace", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "permission_group_id": { + "name": "permission_group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permission_group_workspace_workspace_id_idx": { + "name": "permission_group_workspace_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_workspace_group_workspace_unique": { + "name": "permission_group_workspace_group_workspace_unique", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_group_workspace_permission_group_id_permission_group_id_fk": { + "name": "permission_group_workspace_permission_group_id_permission_group_id_fk", + "tableFrom": "permission_group_workspace", + "tableTo": "permission_group", + "columnsFrom": ["permission_group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_workspace_workspace_id_workspace_id_fk": { + "name": "permission_group_workspace_workspace_id_workspace_id_fk", + "tableFrom": "permission_group_workspace", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_workspace_organization_id_organization_id_fk": { + "name": "permission_group_workspace_organization_id_organization_id_fk", + "tableFrom": "permission_group_workspace", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permissions": { + "name": "permissions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission_type": { + "name": "permission_type", + "type": "permission_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permissions_user_id_idx": { + "name": "permissions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_entity_idx": { + "name": "permissions_entity_idx", + "columns": [ + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_type_idx": { + "name": "permissions_user_entity_type_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_permission_idx": { + "name": "permissions_user_entity_permission_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "permission_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_idx": { + "name": "permissions_user_entity_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_unique_constraint": { + "name": "permissions_unique_constraint", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permissions_user_id_user_id_fk": { + "name": "permissions_user_id_user_id_fk", + "tableFrom": "permissions", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.public_share": { + "name": "public_share", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowed_emails": { + "name": "allowed_emails", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "public_share_token_unique": { + "name": "public_share_token_unique", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "public_share_resource_unique": { + "name": "public_share_resource_unique", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "public_share_resource_id_idx": { + "name": "public_share_resource_id_idx", + "columns": [ + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "public_share_workspace_id_idx": { + "name": "public_share_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "public_share_workspace_id_workspace_id_fk": { + "name": "public_share_workspace_id_workspace_id_fk", + "tableFrom": "public_share", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "public_share_created_by_user_id_fk": { + "name": "public_share_created_by_user_id_fk", + "tableFrom": "public_share", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rate_limit_bucket": { + "name": "rate_limit_bucket", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tokens": { + "name": "tokens", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "last_refill_at": { + "name": "last_refill_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.resume_queue": { + "name": "resume_queue", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "paused_execution_id": { + "name": "paused_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_execution_id": { + "name": "parent_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "new_execution_id": { + "name": "new_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "context_id": { + "name": "context_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resume_input": { + "name": "resume_input", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "queued_at": { + "name": "queued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "failure_reason": { + "name": "failure_reason", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "resume_queue_parent_status_idx": { + "name": "resume_queue_parent_status_idx", + "columns": [ + { + "expression": "parent_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "resume_queue_new_execution_idx": { + "name": "resume_queue_new_execution_idx", + "columns": [ + { + "expression": "new_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "resume_queue_paused_execution_id_paused_executions_id_fk": { + "name": "resume_queue_paused_execution_id_paused_executions_id_fk", + "tableFrom": "resume_queue", + "tableTo": "paused_executions", + "columnsFrom": ["paused_execution_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "impersonated_by": { + "name": "impersonated_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "session_user_id_idx": { + "name": "session_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "session_token_idx": { + "name": "session_token_idx", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_active_organization_id_organization_id_fk": { + "name": "session_active_organization_id_organization_id_fk", + "tableFrom": "session", + "tableTo": "organization", + "columnsFrom": ["active_organization_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.settings": { + "name": "settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "theme": { + "name": "theme", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'system'" + }, + "auto_connect": { + "name": "auto_connect", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "telemetry_enabled": { + "name": "telemetry_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "email_preferences": { + "name": "email_preferences", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "billing_usage_notifications_enabled": { + "name": "billing_usage_notifications_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "show_training_controls": { + "name": "show_training_controls", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "super_user_mode_enabled": { + "name": "super_user_mode_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "mothership_environment": { + "name": "mothership_environment", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'default'" + }, + "error_notifications_enabled": { + "name": "error_notifications_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "snap_to_grid_size": { + "name": "snap_to_grid_size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "show_action_bar": { + "name": "show_action_bar", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "copilot_enabled_models": { + "name": "copilot_enabled_models", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "copilot_auto_allowed_tools": { + "name": "copilot_auto_allowed_tools", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "last_active_workspace_id": { + "name": "last_active_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "settings_user_id_user_id_fk": { + "name": "settings_user_id_user_id_fk", + "tableFrom": "settings", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "settings_user_id_unique": { + "name": "settings_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sim_trigger_state": { + "name": "sim_trigger_state", + "schema": "", + "columns": { + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_key": { + "name": "scope_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "last_fired_at": { + "name": "last_fired_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "sim_trigger_state_workflow_id_workflow_id_fk": { + "name": "sim_trigger_state_workflow_id_workflow_id_fk", + "tableFrom": "sim_trigger_state", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "sim_trigger_state_workflow_id_block_id_scope_key_pk": { + "name": "sim_trigger_state_workflow_id_block_id_scope_key_pk", + "columns": ["workflow_id", "block_id", "scope_key"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skill": { + "name": "skill", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "skill_workspace_name_unique": { + "name": "skill_workspace_name_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skill_workspace_id_workspace_id_fk": { + "name": "skill_workspace_id_workspace_id_fk", + "tableFrom": "skill", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "skill_user_id_user_id_fk": { + "name": "skill_user_id_user_id_fk", + "tableFrom": "skill", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skill_member": { + "name": "skill_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "skill_id": { + "name": "skill_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "skill_member_user_id_idx": { + "name": "skill_member_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "skill_member_unique": { + "name": "skill_member_unique", + "columns": [ + { + "expression": "skill_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skill_member_skill_id_skill_id_fk": { + "name": "skill_member_skill_id_skill_id_fk", + "tableFrom": "skill_member", + "tableTo": "skill", + "columnsFrom": ["skill_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "skill_member_user_id_user_id_fk": { + "name": "skill_member_user_id_user_id_fk", + "tableFrom": "skill_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "skill_member_invited_by_user_id_fk": { + "name": "skill_member_invited_by_user_id_fk", + "tableFrom": "skill_member", + "tableTo": "user", + "columnsFrom": ["invited_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sso_domain": { + "name": "sso_domain", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "verification_token": { + "name": "verification_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "verified_at": { + "name": "verified_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sso_domain_organization_id_idx": { + "name": "sso_domain_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_domain_domain_idx": { + "name": "sso_domain_domain_idx", + "columns": [ + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_domain_org_domain_unique": { + "name": "sso_domain_org_domain_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_domain_verified_unique": { + "name": "sso_domain_verified_unique", + "columns": [ + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "status = 'verified'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sso_domain_organization_id_organization_id_fk": { + "name": "sso_domain_organization_id_organization_id_fk", + "tableFrom": "sso_domain", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sso_domain_created_by_user_id_fk": { + "name": "sso_domain_created_by_user_id_fk", + "tableFrom": "sso_domain", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sso_provider": { + "name": "sso_provider", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "oidc_config": { + "name": "oidc_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "saml_config": { + "name": "saml_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "sso_provider_provider_id_idx": { + "name": "sso_provider_provider_id_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_domain_idx": { + "name": "sso_provider_domain_idx", + "columns": [ + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_user_id_idx": { + "name": "sso_provider_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_organization_id_idx": { + "name": "sso_provider_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sso_provider_user_id_user_id_fk": { + "name": "sso_provider_user_id_user_id_fk", + "tableFrom": "sso_provider", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sso_provider_organization_id_organization_id_fk": { + "name": "sso_provider_organization_id_organization_id_fk", + "tableFrom": "sso_provider", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.subscription": { + "name": "subscription", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "period_start": { + "name": "period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "period_end": { + "name": "period_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancel_at_period_end": { + "name": "cancel_at_period_end", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "cancel_at": { + "name": "cancel_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "canceled_at": { + "name": "canceled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "seats": { + "name": "seats", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "trial_start": { + "name": "trial_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trial_end": { + "name": "trial_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "billing_interval": { + "name": "billing_interval", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_schedule_id": { + "name": "stripe_schedule_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "subscription_reference_status_idx": { + "name": "subscription_reference_status_idx", + "columns": [ + { + "expression": "reference_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "check_enterprise_metadata": { + "name": "check_enterprise_metadata", + "value": "plan != 'enterprise' OR metadata IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.table_jobs": { + "name": "table_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "rows_processed": { + "name": "rows_processed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "table_jobs_one_active_per_table": { + "name": "table_jobs_one_active_per_table", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"table_jobs\".\"status\" = 'running' AND \"table_jobs\".\"type\" <> 'export'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_jobs_watchdog_idx": { + "name": "table_jobs_watchdog_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_jobs_table_started_idx": { + "name": "table_jobs_table_started_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_jobs_table_id_user_table_definitions_id_fk": { + "name": "table_jobs_table_id_user_table_definitions_id_fk", + "tableFrom": "table_jobs", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_jobs_workspace_id_workspace_id_fk": { + "name": "table_jobs_workspace_id_workspace_id_fk", + "tableFrom": "table_jobs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.table_row_executions": { + "name": "table_row_executions", + "schema": "", + "columns": { + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "job_id": { + "name": "job_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "running_block_ids": { + "name": "running_block_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "block_errors": { + "name": "block_errors", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "enrichment_details": { + "name": "enrichment_details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "table_row_executions_table_status_idx": { + "name": "table_row_executions_table_status_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"table_row_executions\".\"status\" IN ('queued', 'running', 'pending')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_row_executions_execution_id_idx": { + "name": "table_row_executions_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"table_row_executions\".\"execution_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_row_executions_table_group_idx": { + "name": "table_row_executions_table_group_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_row_executions_table_id_user_table_definitions_id_fk": { + "name": "table_row_executions_table_id_user_table_definitions_id_fk", + "tableFrom": "table_row_executions", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_row_executions_row_id_user_table_rows_id_fk": { + "name": "table_row_executions_row_id_user_table_rows_id_fk", + "tableFrom": "table_row_executions", + "tableTo": "user_table_rows", + "columnsFrom": ["row_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "table_row_executions_row_id_group_id_pk": { + "name": "table_row_executions_row_id_group_id_pk", + "columns": ["row_id", "group_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.table_run_dispatches": { + "name": "table_run_dispatches", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "cursor": { + "name": "cursor", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "limit": { + "name": "limit", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "processed_count": { + "name": "processed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_manual_run": { + "name": "is_manual_run", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "triggered_by_user_id": { + "name": "triggered_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "requested_at": { + "name": "requested_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "table_run_dispatches_active_idx": { + "name": "table_run_dispatches_active_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_run_dispatches_watchdog_idx": { + "name": "table_run_dispatches_watchdog_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "requested_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_run_dispatches_table_id_user_table_definitions_id_fk": { + "name": "table_run_dispatches_table_id_user_table_definitions_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_run_dispatches_workspace_id_workspace_id_fk": { + "name": "table_run_dispatches_workspace_id_workspace_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_run_dispatches_triggered_by_user_id_user_id_fk": { + "name": "table_run_dispatches_triggered_by_user_id_user_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "user", + "columnsFrom": ["triggered_by_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.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_tokens_used": { + "name": "total_tokens_used", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_cost": { + "name": "total_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_usage_limit": { + "name": "current_usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'5'" + }, + "usage_limit_updated_at": { + "name": "usage_limit_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "current_period_cost": { + "name": "current_period_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "last_period_cost": { + "name": "last_period_cost", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "billed_overage_this_period": { + "name": "billed_overage_this_period", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "pro_period_cost_snapshot": { + "name": "pro_period_cost_snapshot", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "pro_period_cost_snapshot_at": { + "name": "pro_period_cost_snapshot_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "credit_balance": { + "name": "credit_balance", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "total_copilot_cost": { + "name": "total_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_period_copilot_cost": { + "name": "current_period_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "last_period_copilot_cost": { + "name": "last_period_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "total_copilot_tokens": { + "name": "total_copilot_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_copilot_calls": { + "name": "total_copilot_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_mcp_copilot_calls": { + "name": "total_mcp_copilot_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_mcp_copilot_cost": { + "name": "total_mcp_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_period_mcp_copilot_cost": { + "name": "current_period_mcp_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_active": { + "name": "last_active", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "billing_blocked": { + "name": "billing_blocked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "billing_blocked_reason": { + "name": "billing_blocked_reason", + "type": "billing_blocked_reason", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "limit_notifications": { + "name": "limit_notifications", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + } + }, + "indexes": {}, + "foreignKeys": { + "user_stats_user_id_user_id_fk": { + "name": "user_stats_user_id_user_id_fk", + "tableFrom": "user_stats", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_stats_user_id_unique": { + "name": "user_stats_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_table_definitions": { + "name": "user_table_definitions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "schema": { + "name": "schema", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "max_rows": { + "name": "max_rows", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10000 + }, + "row_count": { + "name": "row_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "rows_version": { + "name": "rows_version", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "schema_locked": { + "name": "schema_locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "insert_locked": { + "name": "insert_locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "update_locked": { + "name": "update_locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "delete_locked": { + "name": "delete_locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_table_def_workspace_id_idx": { + "name": "user_table_def_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_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_tenant_data_gin_idx": { + "name": "user_table_rows_tenant_data_gin_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"data\" jsonb_path_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "user_table_rows_workspace_table_idx": { + "name": "user_table_rows_workspace_table_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_position_idx": { + "name": "user_table_rows_table_position_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_order_key_idx": { + "name": "user_table_rows_table_order_key_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "order_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_id_id_idx": { + "name": "user_table_rows_table_id_id_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_table_rows_table_id_user_table_definitions_id_fk": { + "name": "user_table_rows_table_id_user_table_definitions_id_fk", + "tableFrom": "user_table_rows", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_table_rows_workspace_id_workspace_id_fk": { + "name": "user_table_rows_workspace_id_workspace_id_fk", + "tableFrom": "user_table_rows", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_table_rows_created_by_user_id_fk": { + "name": "user_table_rows_created_by_user_id_fk", + "tableFrom": "user_table_rows", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "verification_expires_at_idx": { + "name": "verification_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.waitlist": { + "name": "waitlist", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "waitlist_email_unique": { + "name": "waitlist_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhook": { + "name": "webhook", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "registration_status": { + "name": "registration_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "registration_generation": { + "name": "registration_generation", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "config_fingerprint": { + "name": "config_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prepared_at": { + "name": "prepared_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "routing_key": { + "name": "routing_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_config": { + "name": "provider_config", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "failed_count": { + "name": "failed_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_failed_at": { + "name": "last_failed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "path_deployment_unique": { + "name": "path_deployment_unique", + "columns": [ + { + "expression": "path", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"webhook\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_workflow_deployment_idx": { + "name": "webhook_workflow_deployment_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_routing_key_active_idx": { + "name": "webhook_routing_key_active_idx", + "columns": [ + { + "expression": "routing_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"webhook\".\"archived_at\" IS NULL AND \"webhook\".\"routing_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_archived_at_partial_idx": { + "name": "webhook_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"webhook\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_webhook_on_provider_is_active_workflow_id_deploym_bdeed5468": { + "name": "idx_webhook_on_provider_is_active_workflow_id_deploym_bdeed5468", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_tiktok_credential_id_idx": { + "name": "webhook_tiktok_credential_id_idx", + "columns": [ + { + "expression": "((\"provider_config\")::jsonb ->> 'credentialId')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"webhook\".\"provider\" = 'tiktok' AND \"webhook\".\"is_active\" = true AND \"webhook\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_webhook_on_workflow_id_block_id_updated_at_desc": { + "name": "idx_webhook_on_workflow_id_block_id_updated_at_desc", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_active_registration_unique": { + "name": "webhook_active_registration_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"webhook\".\"registration_status\" = 'active' AND \"webhook\".\"block_id\" IS NOT NULL AND \"webhook\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_candidate_registration_unique": { + "name": "webhook_candidate_registration_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"webhook\".\"registration_status\" = 'candidate' AND \"webhook\".\"block_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_registration_status_generation_idx": { + "name": "webhook_registration_status_generation_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "registration_status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "registration_generation", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "webhook_workflow_id_workflow_id_fk": { + "name": "webhook_workflow_id_workflow_id_fk", + "tableFrom": "webhook", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "webhook_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "webhook_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "webhook", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "webhook_registration_status_check": { + "name": "webhook_registration_status_check", + "value": "\"webhook\".\"registration_status\" IS NULL OR \"webhook\".\"registration_status\" IN ('active', 'candidate', 'retired', 'orphaned')" + }, + "webhook_registration_generation_check": { + "name": "webhook_registration_generation_check", + "value": "\"webhook\".\"registration_generation\" IS NULL OR \"webhook\".\"registration_generation\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.webhook_path_claim": { + "name": "webhook_path_claim", + "schema": "", + "columns": { + "path": { + "name": "path", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "webhook_path_claim_workflow_idx": { + "name": "webhook_path_claim_workflow_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "webhook_path_claim_workflow_id_workflow_id_fk": { + "name": "webhook_path_claim_workflow_id_workflow_id_fk", + "tableFrom": "webhook_path_claim", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "webhook_path_claim_generation_check": { + "name": "webhook_path_claim_generation_check", + "value": "\"webhook_path_claim\".\"generation\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.workflow": { + "name": "workflow", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_synced": { + "name": "last_synced", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "is_deployed": { + "name": "is_deployed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deployed_at": { + "name": "deployed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "is_public_api": { + "name": "is_public_api", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "fork_sync_excluded": { + "name": "fork_sync_excluded", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "run_count": { + "name": "run_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workflow_user_id_idx": { + "name": "workflow_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_id_idx": { + "name": "workflow_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_user_workspace_idx": { + "name": "workflow_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_folder_name_active_unique": { + "name": "workflow_workspace_folder_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"folder_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_sort_idx": { + "name": "workflow_folder_sort_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_archived_at_idx": { + "name": "workflow_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_archived_partial_idx": { + "name": "workflow_workspace_archived_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_user_id_user_id_fk": { + "name": "workflow_user_id_user_id_fk", + "tableFrom": "workflow", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_workspace_id_workspace_id_fk": { + "name": "workflow_workspace_id_workspace_id_fk", + "tableFrom": "workflow", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_folder_id_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_operation": { + "name": "workflow_deployment_operation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "previous_active_version_id": { + "name": "previous_active_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "protocol_version": { + "name": "protocol_version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'preparing'" + }, + "component_readiness": { + "name": "component_readiness", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "request_hash": { + "name": "request_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_deployment_operation_workflow_generation_unique": { + "name": "workflow_deployment_operation_workflow_generation_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "generation", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_idempotency_unique": { + "name": "workflow_deployment_operation_workflow_idempotency_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_deployment_operation\".\"idempotency_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_in_flight_unique": { + "name": "workflow_deployment_operation_workflow_in_flight_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_deployment_operation\".\"status\" IN ('preparing', 'activating')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_status_idx": { + "name": "workflow_deployment_operation_workflow_status_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_deployment_version_idx": { + "name": "workflow_deployment_operation_deployment_version_idx", + "columns": [ + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_version_generation_idx": { + "name": "workflow_deployment_operation_workflow_version_generation_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "generation", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_deployment_operation_workflow_id_workflow_id_fk": { + "name": "workflow_deployment_operation_workflow_id_workflow_id_fk", + "tableFrom": "workflow_deployment_operation", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_deployment_operation_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_deployment_operation_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_deployment_operation", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_deployment_operation_previous_active_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_deployment_operation_previous_active_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_deployment_operation", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["previous_active_version_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "workflow_deployment_operation_action_check": { + "name": "workflow_deployment_operation_action_check", + "value": "\"workflow_deployment_operation\".\"action\" IN ('deploy', 'activate')" + }, + "workflow_deployment_operation_status_check": { + "name": "workflow_deployment_operation_status_check", + "value": "\"workflow_deployment_operation\".\"status\" IN ('preparing', 'activating', 'active', 'failed', 'superseded')" + }, + "workflow_deployment_operation_generation_check": { + "name": "workflow_deployment_operation_generation_check", + "value": "\"workflow_deployment_operation\".\"generation\" > 0" + }, + "workflow_deployment_operation_protocol_version_check": { + "name": "workflow_deployment_operation_protocol_version_check", + "value": "\"workflow_deployment_operation\".\"protocol_version\" > 0" + } + }, + "isRLSEnabled": false + }, + "public.workflow_deployment_version": { + "name": "workflow_deployment_version", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workflow_deployment_version_workflow_version_unique": { + "name": "workflow_deployment_version_workflow_version_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_version_workflow_active_idx": { + "name": "workflow_deployment_version_workflow_active_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_version_created_at_idx": { + "name": "workflow_deployment_version_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_deployment_version_workflow_id_workflow_id_fk": { + "name": "workflow_deployment_version_workflow_id_workflow_id_fk", + "tableFrom": "workflow_deployment_version", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_edges": { + "name": "workflow_edges", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_block_id": { + "name": "source_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_block_id": { + "name": "target_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_handle": { + "name": "source_handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_handle": { + "name": "target_handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_edges_workflow_id_idx": { + "name": "workflow_edges_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_edges_workflow_source_idx": { + "name": "workflow_edges_workflow_source_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_edges_workflow_target_idx": { + "name": "workflow_edges_workflow_target_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_edges_workflow_id_workflow_id_fk": { + "name": "workflow_edges_workflow_id_workflow_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_edges_source_block_id_workflow_blocks_id_fk": { + "name": "workflow_edges_source_block_id_workflow_blocks_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow_blocks", + "columnsFrom": ["source_block_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_edges_target_block_id_workflow_blocks_id_fk": { + "name": "workflow_edges_target_block_id_workflow_blocks_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow_blocks", + "columnsFrom": ["target_block_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_execution_logs": { + "name": "workflow_execution_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state_snapshot_id": { + "name": "state_snapshot_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_duration_ms": { + "name": "total_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "execution_data": { + "name": "execution_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "cost": { + "name": "cost", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "cost_total": { + "name": "cost_total", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "models_used": { + "name": "models_used", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "files": { + "name": "files", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_execution_logs_workflow_id_idx": { + "name": "workflow_execution_logs_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_state_snapshot_id_idx": { + "name": "workflow_execution_logs_state_snapshot_id_idx", + "columns": [ + { + "expression": "state_snapshot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_deployment_version_id_idx": { + "name": "workflow_execution_logs_deployment_version_id_idx", + "columns": [ + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_trigger_idx": { + "name": "workflow_execution_logs_trigger_idx", + "columns": [ + { + "expression": "trigger", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_level_idx": { + "name": "workflow_execution_logs_level_idx", + "columns": [ + { + "expression": "level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_started_at_idx": { + "name": "workflow_execution_logs_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_execution_id_unique": { + "name": "workflow_execution_logs_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workflow_started_at_idx": { + "name": "workflow_execution_logs_workflow_started_at_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workspace_started_at_idx": { + "name": "workflow_execution_logs_workspace_started_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workspace_started_at_id_desc_idx": { + "name": "workflow_execution_logs_workspace_started_at_id_desc_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"started_at\" DESC NULLS LAST", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "\"id\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workspace_cost_total_idx": { + "name": "workflow_execution_logs_workspace_cost_total_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_total", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_models_used_idx": { + "name": "workflow_execution_logs_models_used_idx", + "columns": [ + { + "expression": "models_used", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "workflow_execution_logs_workspace_ended_at_id_idx": { + "name": "workflow_execution_logs_workspace_ended_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"ended_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_running_started_at_idx": { + "name": "workflow_execution_logs_running_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "status = 'running'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_completed_ended_at_idx": { + "name": "workflow_execution_logs_completed_ended_at_idx", + "columns": [ + { + "expression": "ended_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_execution_logs\".\"status\" = 'completed' AND \"workflow_execution_logs\".\"level\" = 'info' AND \"workflow_execution_logs\".\"ended_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_execution_logs_workflow_id_workflow_id_fk": { + "name": "workflow_execution_logs_workflow_id_workflow_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workflow_execution_logs_workspace_id_workspace_id_fk": { + "name": "workflow_execution_logs_workspace_id_workspace_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_execution_logs_state_snapshot_id_workflow_execution_snapshots_id_fk": { + "name": "workflow_execution_logs_state_snapshot_id_workflow_execution_snapshots_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow_execution_snapshots", + "columnsFrom": ["state_snapshot_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "workflow_execution_logs_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_execution_logs_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_execution_snapshots": { + "name": "workflow_execution_snapshots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state_hash": { + "name": "state_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state_data": { + "name": "state_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_snapshots_workflow_id_idx": { + "name": "workflow_snapshots_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_hash_idx": { + "name": "workflow_snapshots_hash_idx", + "columns": [ + { + "expression": "state_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_workflow_hash_idx": { + "name": "workflow_snapshots_workflow_hash_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "state_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_created_at_idx": { + "name": "workflow_snapshots_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_execution_snapshots_workflow_id_workflow_id_fk": { + "name": "workflow_execution_snapshots_workflow_id_workflow_id_fk", + "tableFrom": "workflow_execution_snapshots", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_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": "'{}'" + }, + "parameter_description_overrides": { + "name": "parameter_description_overrides", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'::json" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_mcp_tool_server_id_idx": { + "name": "workflow_mcp_tool_server_id_idx", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_tool_workflow_id_idx": { + "name": "workflow_mcp_tool_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_tool_server_workflow_unique": { + "name": "workflow_mcp_tool_server_workflow_unique", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_mcp_tool\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_tool_archived_at_partial_idx": { + "name": "workflow_mcp_tool_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_mcp_tool\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_mcp_tool_server_id_workflow_mcp_server_id_fk": { + "name": "workflow_mcp_tool_server_id_workflow_mcp_server_id_fk", + "tableFrom": "workflow_mcp_tool", + "tableTo": "workflow_mcp_server", + "columnsFrom": ["server_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_mcp_tool_workflow_id_workflow_id_fk": { + "name": "workflow_mcp_tool_workflow_id_workflow_id_fk", + "tableFrom": "workflow_mcp_tool", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_schedule": { + "name": "workflow_schedule", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deployment_operation_id": { + "name": "deployment_operation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cron_expression": { + "name": "cron_expression", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_run_at": { + "name": "next_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_ran_at": { + "name": "last_ran_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_queued_at": { + "name": "last_queued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trigger_type": { + "name": "trigger_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'UTC'" + }, + "failed_count": { + "name": "failed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "infra_retry_count": { + "name": "infra_retry_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "last_failed_at": { + "name": "last_failed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "source_type": { + "name": "source_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'workflow'" + }, + "job_title": { + "name": "job_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lifecycle": { + "name": "lifecycle", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'persistent'" + }, + "success_condition": { + "name": "success_condition", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "max_runs": { + "name": "max_runs", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "run_count": { + "name": "run_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "source_chat_id": { + "name": "source_chat_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_task_name": { + "name": "source_task_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_user_id": { + "name": "source_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_workspace_id": { + "name": "source_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "job_history": { + "name": "job_history", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "contexts": { + "name": "contexts", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "excluded_dates": { + "name": "excluded_dates", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "ends_at": { + "name": "ends_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_schedule_workflow_block_deployment_unique": { + "name": "workflow_schedule_workflow_block_deployment_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_schedule\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_workflow_deployment_idx": { + "name": "workflow_schedule_workflow_deployment_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_archived_at_partial_idx": { + "name": "workflow_schedule_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_schedule\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workflow_schedule_on_source_workspace_id_source_t_c07f3bba6": { + "name": "idx_workflow_schedule_on_source_workspace_id_source_t_c07f3bba6", + "columns": [ + { + "expression": "source_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_due_workflow_idx": { + "name": "workflow_schedule_due_workflow_idx", + "columns": [ + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_schedule\".\"archived_at\" IS NULL AND \"workflow_schedule\".\"status\" NOT IN ('disabled', 'completed') AND (\"workflow_schedule\".\"source_type\" = 'workflow' OR \"workflow_schedule\".\"source_type\" IS NULL)", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_due_job_idx": { + "name": "workflow_schedule_due_job_idx", + "columns": [ + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_schedule\".\"archived_at\" IS NULL AND \"workflow_schedule\".\"status\" NOT IN ('disabled', 'completed') AND \"workflow_schedule\".\"source_type\" = 'job'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_schedule_workflow_id_workflow_id_fk": { + "name": "workflow_schedule_workflow_id_workflow_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_schedule_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_deployment_operation_id_workflow_deployment_operation_id_fk": { + "name": "workflow_schedule_deployment_operation_id_workflow_deployment_operation_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow_deployment_operation", + "columnsFrom": ["deployment_operation_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workflow_schedule_source_user_id_user_id_fk": { + "name": "workflow_schedule_source_user_id_user_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "user", + "columnsFrom": ["source_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_source_workspace_id_workspace_id_fk": { + "name": "workflow_schedule_source_workspace_id_workspace_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workspace", + "columnsFrom": ["source_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_subflows": { + "name": "workflow_subflows", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_subflows_workflow_id_idx": { + "name": "workflow_subflows_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_subflows_workflow_type_idx": { + "name": "workflow_subflows_workflow_type_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_subflows_workflow_id_workflow_id_fk": { + "name": "workflow_subflows_workflow_id_workflow_id_fk", + "tableFrom": "workflow_subflows", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace": { + "name": "workspace", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'#33C482'" + }, + "logo_url": { + "name": "logo_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_mode": { + "name": "workspace_mode", + "type": "workspace_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'grandfathered_shared'" + }, + "billed_account_user_id": { + "name": "billed_account_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "allow_personal_api_keys": { + "name": "allow_personal_api_keys", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "inbox_enabled": { + "name": "inbox_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "inbox_address": { + "name": "inbox_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "inbox_provider_id": { + "name": "inbox_provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "organization_assigned_at": { + "name": "organization_assigned_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "forked_from_workspace_id": { + "name": "forked_from_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_owner_id_idx": { + "name": "workspace_owner_id_idx", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_organization_id_idx": { + "name": "workspace_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_mode_idx": { + "name": "workspace_mode_idx", + "columns": [ + { + "expression": "workspace_mode", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_forked_from_workspace_id_idx": { + "name": "workspace_forked_from_workspace_id_idx", + "columns": [ + { + "expression": "forked_from_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_owner_id_user_id_fk": { + "name": "workspace_owner_id_user_id_fk", + "tableFrom": "workspace", + "tableTo": "user", + "columnsFrom": ["owner_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_organization_id_organization_id_fk": { + "name": "workspace_organization_id_organization_id_fk", + "tableFrom": "workspace", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspace_billed_account_user_id_user_id_fk": { + "name": "workspace_billed_account_user_id_user_id_fk", + "tableFrom": "workspace", + "tableTo": "user", + "columnsFrom": ["billed_account_user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "workspace_forked_from_workspace_id_workspace_id_fk": { + "name": "workspace_forked_from_workspace_id_workspace_id_fk", + "tableFrom": "workspace", + "tableTo": "workspace", + "columnsFrom": ["forked_from_workspace_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "workspace_storage_used_bytes_non_negative": { + "name": "workspace_storage_used_bytes_non_negative", + "value": "\"workspace\".\"storage_used_bytes\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.workspace_byok_keys": { + "name": "workspace_byok_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_api_key": { + "name": "encrypted_api_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_byok_workspace_provider_idx": { + "name": "workspace_byok_workspace_provider_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_byok_keys_workspace_id_workspace_id_fk": { + "name": "workspace_byok_keys_workspace_id_workspace_id_fk", + "tableFrom": "workspace_byok_keys", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_byok_keys_created_by_user_id_fk": { + "name": "workspace_byok_keys_created_by_user_id_fk", + "tableFrom": "workspace_byok_keys", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_environment": { + "name": "workspace_environment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_environment_workspace_unique": { + "name": "workspace_environment_workspace_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_environment_workspace_id_workspace_id_fk": { + "name": "workspace_environment_workspace_id_workspace_id_fk", + "tableFrom": "workspace_environment", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file": { + "name": "workspace_file", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_file_workspace_id_idx": { + "name": "workspace_file_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_key_idx": { + "name": "workspace_file_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_deleted_at_idx": { + "name": "workspace_file_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_workspace_deleted_partial_idx": { + "name": "workspace_file_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_file\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_file_workspace_id_workspace_id_fk": { + "name": "workspace_file_workspace_id_workspace_id_fk", + "tableFrom": "workspace_file", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_file_uploaded_by_user_id_fk": { + "name": "workspace_file_uploaded_by_user_id_fk", + "tableFrom": "workspace_file", + "tableTo": "user", + "columnsFrom": ["uploaded_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workspace_file_key_unique": { + "name": "workspace_file_key_unique", + "nullsNotDistinct": false, + "columns": ["key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_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 + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "original_name": { + "name": "original_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_files_key_active_unique": { + "name": "workspace_files_key_active_unique", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_files\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_workspace_folder_name_active_unique": { + "name": "workspace_files_workspace_folder_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"folder_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "original_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_files\".\"deleted_at\" IS NULL AND \"workspace_files\".\"context\" = 'workspace' AND \"workspace_files\".\"workspace_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_chat_display_name_unique": { + "name": "workspace_files_chat_display_name_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "display_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_files\".\"context\" = 'mothership' AND \"workspace_files\".\"chat_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_key_idx": { + "name": "workspace_files_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_user_id_idx": { + "name": "workspace_files_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_workspace_id_idx": { + "name": "workspace_files_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_folder_id_idx": { + "name": "workspace_files_folder_id_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_context_idx": { + "name": "workspace_files_context_idx", + "columns": [ + { + "expression": "context", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_chat_id_idx": { + "name": "workspace_files_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_deleted_at_idx": { + "name": "workspace_files_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_workspace_deleted_partial_idx": { + "name": "workspace_files_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_files\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_files_user_id_user_id_fk": { + "name": "workspace_files_user_id_user_id_fk", + "tableFrom": "workspace_files", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_files_workspace_id_workspace_id_fk": { + "name": "workspace_files_workspace_id_workspace_id_fk", + "tableFrom": "workspace_files", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_files_folder_id_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_fork_block_map": { + "name": "workspace_fork_block_map", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_workflow_id": { + "name": "parent_workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_block_id": { + "name": "parent_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_workflow_id": { + "name": "child_workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_block_id": { + "name": "child_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_block_map_child_ws_parent_unique": { + "name": "workspace_fork_block_map_child_ws_parent_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_block_map_child_ws_child_unique": { + "name": "workspace_fork_block_map_child_ws_child_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "child_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_block_map_child_ws_parent_wf_idx": { + "name": "workspace_fork_block_map_child_ws_parent_wf_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_block_map_child_ws_child_wf_idx": { + "name": "workspace_fork_block_map_child_ws_child_wf_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "child_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_block_map_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_block_map_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_block_map", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_dependent_value": { + "name": "workspace_fork_dependent_value", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_workflow_id": { + "name": "target_workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_block_id": { + "name": "target_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sub_block_key": { + "name": "sub_block_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_dependent_value_child_ws_wf_idx": { + "name": "workspace_fork_dependent_value_child_ws_wf_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_dependent_value_field_unique": { + "name": "workspace_fork_dependent_value_field_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sub_block_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_dependent_value_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_dependent_value_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_dependent_value", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_promote_run": { + "name": "workspace_fork_promote_run", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_workspace_id": { + "name": "source_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_workspace_id": { + "name": "target_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "direction": { + "name": "direction", + "type": "workspace_fork_promote_direction", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "snapshot": { + "name": "snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_promote_run_child_ws_target_unique": { + "name": "workspace_fork_promote_run_child_ws_target_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_promote_run_target_ws_idx": { + "name": "workspace_fork_promote_run_target_ws_idx", + "columns": [ + { + "expression": "target_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_promote_run_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_promote_run_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_promote_run", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_fork_promote_run_created_by_user_id_fk": { + "name": "workspace_fork_promote_run_created_by_user_id_fk", + "tableFrom": "workspace_fork_promote_run", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_resource_map": { + "name": "workspace_fork_resource_map", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "workspace_fork_resource_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "parent_resource_id": { + "name": "parent_resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_resource_id": { + "name": "child_resource_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_resource_map_child_ws_idx": { + "name": "workspace_fork_resource_map_child_ws_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_resource_map_child_ws_type_idx": { + "name": "workspace_fork_resource_map_child_ws_type_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_resource_map_child_type_parent_unique": { + "name": "workspace_fork_resource_map_child_type_parent_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_resource_map_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_resource_map_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_resource_map", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_fork_resource_map_created_by_user_id_fk": { + "name": "workspace_fork_resource_map_created_by_user_id_fk", + "tableFrom": "workspace_fork_resource_map", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.academy_cert_status": { + "name": "academy_cert_status", + "schema": "public", + "values": ["active", "revoked", "expired"] + }, + "public.background_work_kind": { + "name": "background_work_kind", + "schema": "public", + "values": ["deployment_side_effects", "fork_content_copy", "fork_sync", "fork_rollback"] + }, + "public.background_work_status_value": { + "name": "background_work_status_value", + "schema": "public", + "values": ["pending", "processing", "completed", "completed_with_warnings", "failed"] + }, + "public.billing_blocked_reason": { + "name": "billing_blocked_reason", + "schema": "public", + "values": ["payment_failed", "dispute"] + }, + "public.billing_entity_type": { + "name": "billing_entity_type", + "schema": "public", + "values": ["user", "organization"] + }, + "public.chat_type": { + "name": "chat_type", + "schema": "public", + "values": ["mothership", "copilot"] + }, + "public.copilot_async_tool_status": { + "name": "copilot_async_tool_status", + "schema": "public", + "values": ["pending", "running", "completed", "failed", "cancelled", "delivered"] + }, + "public.copilot_run_status": { + "name": "copilot_run_status", + "schema": "public", + "values": ["active", "paused_waiting_for_tool", "resuming", "complete", "error", "cancelled"] + }, + "public.credential_member_role": { + "name": "credential_member_role", + "schema": "public", + "values": ["admin", "member"] + }, + "public.credential_member_status": { + "name": "credential_member_status", + "schema": "public", + "values": ["active", "pending", "revoked"] + }, + "public.credential_type": { + "name": "credential_type", + "schema": "public", + "values": ["oauth", "env_workspace", "env_personal", "service_account"] + }, + "public.data_drain_cadence": { + "name": "data_drain_cadence", + "schema": "public", + "values": ["hourly", "daily"] + }, + "public.data_drain_destination": { + "name": "data_drain_destination", + "schema": "public", + "values": ["s3", "gcs", "azure_blob", "datadog", "bigquery", "snowflake", "webhook"] + }, + "public.data_drain_run_status": { + "name": "data_drain_run_status", + "schema": "public", + "values": ["running", "success", "failed"] + }, + "public.data_drain_run_trigger": { + "name": "data_drain_run_trigger", + "schema": "public", + "values": ["cron", "manual"] + }, + "public.data_drain_source": { + "name": "data_drain_source", + "schema": "public", + "values": ["workflow_logs", "job_logs", "audit_logs", "copilot_chats", "copilot_runs"] + }, + "public.execution_large_value_reference_source": { + "name": "execution_large_value_reference_source", + "schema": "public", + "values": ["execution_log", "paused_snapshot"] + }, + "public.invitation_kind": { + "name": "invitation_kind", + "schema": "public", + "values": ["organization", "workspace"] + }, + "public.invitation_membership_intent": { + "name": "invitation_membership_intent", + "schema": "public", + "values": ["internal", "external"] + }, + "public.invitation_status": { + "name": "invitation_status", + "schema": "public", + "values": ["pending", "accepted", "rejected", "cancelled", "expired"] + }, + "public.permission_type": { + "name": "permission_type", + "schema": "public", + "values": ["admin", "write", "read"] + }, + "public.usage_log_category": { + "name": "usage_log_category", + "schema": "public", + "values": ["model", "fixed", "tool"] + }, + "public.usage_log_source": { + "name": "usage_log_source", + "schema": "public", + "values": [ + "workflow", + "wand", + "copilot", + "workspace-chat", + "mcp_copilot", + "mothership_block", + "knowledge-base", + "voice-input", + "enrichment" + ] + }, + "public.workspace_fork_promote_direction": { + "name": "workspace_fork_promote_direction", + "schema": "public", + "values": ["push", "pull"] + }, + "public.workspace_fork_resource_type": { + "name": "workspace_fork_resource_type", + "schema": "public", + "values": [ + "workflow", + "oauth_credential", + "service_account_credential", + "env_var", + "table", + "knowledge_base", + "knowledge_document", + "file", + "mcp_server", + "workflow_mcp_server", + "custom_tool", + "skill" + ] + }, + "public.workspace_mode": { + "name": "workspace_mode", + "schema": "public", + "values": ["personal", "organization", "grandfathered_shared"] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/db/migrations/meta/_journal.json b/packages/db/migrations/meta/_journal.json index 3b2a13dd198..d200a6d5801 100644 --- a/packages/db/migrations/meta/_journal.json +++ b/packages/db/migrations/meta/_journal.json @@ -1888,7 +1888,7 @@ { "idx": 270, "version": "7", - "when": 1784938826038, + "when": 1784960447583, "tag": "0270_table_mutation_locks", "breakpoints": true } From 71b87467e6ebb3ad4e1e132130ec32c50be20cd5 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Fri, 24 Jul 2026 23:53:00 -0700 Subject: [PATCH 03/15] fix(tables): close lock gaps in async import and undo/redo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Assert insert (and delete for replace) at the start of the import worker. Replace deleted every row before the first insert assert, so an insert-locked table was wiped and then failed, leaving it empty. - Run the delete/update worker start checks through assertRowDelete / assertRowUpdate instead of reading the lock flags directly, so the worker and its enqueue site apply identical rules — including the workflow-column exemption a bulk update was being cancelled despite. - Make undo/redo verb mapping direction-aware for column ops: dropping or retyping a column needs the delete lock clear too, matching assertColumnDestructive, so those steps are skipped instead of stranded. --- apps/sim/hooks/use-table-undo.ts | 44 +++++++++++++----------- apps/sim/lib/table/delete-runner.ts | 9 +++-- apps/sim/lib/table/import-runner.test.ts | 22 ++++++++++++ apps/sim/lib/table/import-runner.ts | 9 +++++ apps/sim/lib/table/update-runner.ts | 23 ++++++++++--- 5 files changed, 79 insertions(+), 28 deletions(-) diff --git a/apps/sim/hooks/use-table-undo.ts b/apps/sim/hooks/use-table-undo.ts index 2063da91b20..2cf6f907550 100644 --- a/apps/sim/hooks/use-table-undo.ts +++ b/apps/sim/hooks/use-table-undo.ts @@ -26,47 +26,51 @@ const logger = createLogger('useTableUndo') * re-inserts), so the verb depends on direction. Column ops are always `schema`; * rename/reorder touch no locked verb (`null`). */ -function undoActionVerb(action: TableUndoAction, direction: 'undo' | 'redo'): TableLockKind | null { +function undoActionVerbs(action: TableUndoAction, direction: 'undo' | 'redo'): TableLockKind[] { + const undoing = direction === 'undo' switch (action.type) { case 'update-cell': case 'update-cells': case 'clear-cells': - return 'update' + return ['update'] case 'create-row': case 'create-rows': - return direction === 'undo' ? 'delete' : 'insert' + return undoing ? ['delete'] : ['insert'] case 'delete-rows': - return direction === 'undo' ? 'insert' : 'delete' + return undoing ? ['insert'] : ['delete'] + // Dropping or retyping a column clears its value from every row, so those + // directions need the delete lock clear too — mirroring + // `assertColumnDestructive`. Undoing a delete-column re-adds the column and + // writes the saved cells back, which is a row update. case 'create-column': + return undoing ? ['schema', 'delete'] : ['schema'] case 'delete-column': - case 'rename-column': + return undoing ? ['schema', 'update'] : ['schema', 'delete'] case 'update-column-type': + return ['schema', 'delete'] + case 'rename-column': case 'toggle-column-constraint': - return 'schema' + return ['schema'] default: - return null + return [] } } -/** The lock (if any) that would block replaying `action` in `direction`. */ +const LOCK_FLAG_BY_KIND: Record = { + insert: 'insertLocked', + update: 'updateLocked', + delete: 'deleteLocked', + schema: 'schemaLocked', +} + +/** The first lock (if any) that would block replaying `action` in `direction`. */ function undoActionBlockedBy( action: TableUndoAction, direction: 'undo' | 'redo', locks: TableLocks | undefined ): TableLockKind | null { if (!locks) return null - switch (undoActionVerb(action, direction)) { - case 'insert': - return locks.insertLocked ? 'insert' : null - case 'update': - return locks.updateLocked ? 'update' : null - case 'delete': - return locks.deleteLocked ? 'delete' : null - case 'schema': - return locks.schemaLocked ? 'schema' : null - default: - return null - } + return undoActionVerbs(action, direction).find((kind) => locks[LOCK_FLAG_BY_KIND[kind]]) ?? null } const BLOCKED_UNDO_MESSAGE: Record = { diff --git a/apps/sim/lib/table/delete-runner.ts b/apps/sim/lib/table/delete-runner.ts index 6700525730a..27a382fe549 100644 --- a/apps/sim/lib/table/delete-runner.ts +++ b/apps/sim/lib/table/delete-runner.ts @@ -12,7 +12,7 @@ import { markJobReady, updateJobProgress, } from '@/lib/table/jobs/service' -import { unsafeMutationProof } from '@/lib/table/mutation-locks' +import { assertRowDelete, type MutationProof, TableLockedError } from '@/lib/table/mutation-locks' import { deletePageByIds, selectRowIdPage } from '@/lib/table/rows/ordering' import { getTableById } from '@/lib/table/service' import { buildFilterClause } from '@/lib/table/sql' @@ -76,7 +76,11 @@ export async function runTableDelete(payload: TableDeletePayload): Promise // mid-flight: committed pages are never rolled back, so aborting would // leave the same half-done state as a cancel that nobody asked for — an // admin stops it explicitly via `POST /api/table/[tableId]/job/cancel`. - if (table.locks?.deleteLocked) { + let deleteProof: MutationProof<'delete'> + try { + deleteProof = assertRowDelete(table) + } catch (err) { + if (!(err instanceof TableLockedError)) throw err logger.info(`[${requestId}] Delete job stopped — table was delete-locked before it started`, { tableId, jobId, @@ -85,7 +89,6 @@ export async function runTableDelete(payload: TableDeletePayload): Promise void appendTableEvent({ kind: 'job', type: 'delete', tableId, jobId, status: 'canceled' }) return } - const deleteProof = unsafeMutationProof<'delete'>() const filterClause = filter ? buildFilterClause(filter, USER_TABLE_ROWS_SQL_NAME, table.schema.columns) diff --git a/apps/sim/lib/table/import-runner.test.ts b/apps/sim/lib/table/import-runner.test.ts index 29b076a1806..df66a5e0984 100644 --- a/apps/sim/lib/table/import-runner.test.ts +++ b/apps/sim/lib/table/import-runner.test.ts @@ -98,6 +98,28 @@ describe('runTableImport source-file cleanup', () => { mockDeleteFile.mockResolvedValue(undefined) }) + it('fails an insert-locked replace before anything is deleted', async () => { + // `deleteAllTableRows` only asserts the delete lock, so without an up-front + // insert assert a replace on an insert-locked table would wipe every row + // and then fail on the first insert, leaving the table empty. + mockGetTableById.mockResolvedValue({ + ...table, + locks: { schemaLocked: false, insertLocked: true, updateLocked: false, deleteLocked: false }, + }) + + mockMarkJobFailed.mockResolvedValue(undefined) + + await runTableImport(buildPayload({ mode: 'replace' })) + + expect(mockMarkJobFailed).toHaveBeenCalledWith( + 'tbl_1', + 'job_1', + expect.stringMatching(/insert-locked/i) + ) + // Bailed before the file was even read, so the delete could never run. + expect(mockDownloadFileStream).not.toHaveBeenCalled() + }) + it('deletes the single-use source object by default', async () => { await runTableImport(buildPayload()) diff --git a/apps/sim/lib/table/import-runner.ts b/apps/sim/lib/table/import-runner.ts index 91885fc5378..9380ee0e88a 100644 --- a/apps/sim/lib/table/import-runner.ts +++ b/apps/sim/lib/table/import-runner.ts @@ -28,6 +28,7 @@ import { setTableSchemaForImport, } from '@/lib/table/import-data' import { markJobFailed, markJobReady, updateJobProgress } from '@/lib/table/jobs/service' +import { assertRowDelete, assertRowInsert } from '@/lib/table/mutation-locks' import { nextImportStartOrderKey, nextImportStartPosition } from '@/lib/table/rows/ordering' import { getTableById } from '@/lib/table/service' import { deleteFile, downloadFileStream, headObject } from '@/lib/uploads/core/storage-service' @@ -97,6 +98,14 @@ export async function runTableImport(payload: TableImportPayload): Promise if (!loaded) throw new Error(`Import target table ${tableId} not found`) const table = loaded + // Every mode ends in row inserts, and `replace` deletes first. Assert both + // verbs here — before the file is even read — so an insert-locked table + // fails up front instead of after `deleteAllTableRows` has already wiped it. + // (The sync replace path gets this for free from `replaceTableRowsWithTx`, + // which asserts both in one place; this path deletes and inserts separately.) + assertRowInsert(table) + if (mode === 'replace') assertRowDelete(table) + // Total byte size for the progress estimate — a cheap HEAD, no download. May be null on // the local dev provider, in which case the bar stays indeterminate (rows still show). const totalBytes = (await headObject(fileKey, 'workspace'))?.size ?? 0 diff --git a/apps/sim/lib/table/update-runner.ts b/apps/sim/lib/table/update-runner.ts index 664bb0bd7ed..ab026b28a66 100644 --- a/apps/sim/lib/table/update-runner.ts +++ b/apps/sim/lib/table/update-runner.ts @@ -12,7 +12,12 @@ import { markJobReady, updateJobProgress, } from '@/lib/table/jobs/service' -import { unsafeMutationProof } from '@/lib/table/mutation-locks' +import { + assertRowUpdate, + type MutationProof, + patchColumnIds, + TableLockedError, +} from '@/lib/table/mutation-locks' import { selectRowDataPage, updatePageByIds } from '@/lib/table/rows/ordering' import { getTableById } from '@/lib/table/service' import { buildFilterClause } from '@/lib/table/sql' @@ -71,9 +76,18 @@ export async function runTableUpdate(payload: TableUpdatePayload): Promise // A lock set between admission and this worker starting (queue delay, or a // retry re-entering here) means nothing has been written yet — honor it and - // stop. See `delete-runner.ts` for the full rationale, including why a - // mid-flight lock does NOT abort a run that has already committed pages. - if (table.locks?.updateLocked) { + // stop. Runs through `assertRowUpdate` rather than reading `updateLocked` + // directly so the enqueue site and the worker apply identical rules, + // including the workflow-column exemption: a patch touching only + // workflow-group outputs is allowed under an update lock, and cancelling it + // here would contradict the enqueue assert that just let it through. + // See `delete-runner.ts` for why a mid-flight lock does NOT abort a run + // that has already committed pages. + let updateProof: MutationProof<'update'> + try { + updateProof = assertRowUpdate(table, patchColumnIds(data)) + } catch (err) { + if (!(err instanceof TableLockedError)) throw err logger.info(`[${requestId}] Update job stopped — table was update-locked before it started`, { tableId, jobId, @@ -82,7 +96,6 @@ export async function runTableUpdate(payload: TableUpdatePayload): Promise void appendTableEvent({ kind: 'job', type: 'update', tableId, jobId, status: 'canceled' }) return } - const updateProof = unsafeMutationProof<'update'>() const filterClause = buildFilterClause(filter, USER_TABLE_ROWS_SQL_NAME, table.schema.columns) if (!filterClause) throw new Error('Filter is required for bulk update') From 9369f0a05e9991cceef0e0db1650dde0e053f565 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Sat, 25 Jul 2026 00:05:39 -0700 Subject: [PATCH 04/15] fix(tables): stop locks over-blocking workflow runs, duplicate, and imports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Run / Re-run / Stop no longer inherit the cell-edit gate: they write only workflow-output columns (which the update lock exempts) and Stop is a cancel - Duplicate needs only the insert lock — it inserts a full copied row, so it stays available on an append-only table - updateWorkflowGroup asserts the destructive rule only when a patch actually drops or remaps output columns; rename / autoRun / mapping edits need just the schema lock - Gate the import-async enqueue on insert (and delete for replace) so a locked table 423s instead of claiming the write-job slot and failing in the worker - Disable Import CSV under an insert lock, and wire the expanded cell editor and the previously-unused stable add-row handler to the lock-aware flags --- .../api/table/[tableId]/import-async/route.ts | 6 ++++++ .../components/context-menu/context-menu.tsx | 21 +++++++++++++++---- .../components/table-grid/table-grid.tsx | 10 +++++---- .../[workspaceId]/tables/[tableId]/table.tsx | 4 +++- apps/sim/lib/table/workflow-groups/service.ts | 12 +++++++++-- 5 files changed, 42 insertions(+), 11 deletions(-) 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 b440dfaf680..08a96062bd9 100644 --- a/apps/sim/app/api/table/[tableId]/import-async/route.ts +++ b/apps/sim/app/api/table/[tableId]/import-async/route.ts @@ -10,6 +10,7 @@ import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { runTableImport, type TableImportPayload } from '@/lib/table/import-runner' import { markTableJobRunning, releaseJobClaim } from '@/lib/table/jobs/service' +import { assertRowDelete, assertRowInsert } from '@/lib/table/mutation-locks' import { getUserSettings } from '@/lib/users/queries' import { accessError, checkAccess } from '@/app/api/table/utils' @@ -53,6 +54,11 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro return NextResponse.json({ error: 'Cannot import into an archived table' }, { status: 400 }) } + // Gate the locks before claiming the single write-job slot, so a locked table + // reports 423 here instead of holding the slot and failing inside the worker. + assertRowInsert(table) + if (mode === 'replace') assertRowDelete(table) + const ext = fileName.split('.').pop()?.toLowerCase() if (ext !== 'csv' && ext !== 'tsv') { return NextResponse.json({ error: 'Only CSV and TSV files are supported' }, { status: 400 }) 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 997d1943627..34d6561b689 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 @@ -48,6 +48,12 @@ interface ContextMenuProps { workflowCellScoped?: boolean disableEdit?: boolean disableInsert?: boolean + /** + * Duplicate is a one-shot insert carrying the copied row's data, so it needs + * only the insert lock — unlike the blank-row inserts above it, which also + * need the update lock to be fillable. + */ + disableDuplicate?: boolean disableDelete?: boolean } @@ -71,6 +77,7 @@ export function ContextMenu({ workflowCellScoped = false, disableEdit = false, disableInsert = false, + disableDuplicate = false, disableDelete = false, }: ContextMenuProps) { const count = selectedRowCount.toLocaleString() @@ -132,20 +139,23 @@ export function ContextMenu({ View execution
)} + {/* Not gated on `disableEdit`: these write only workflow-output columns, + which the update lock exempts, and Stop is a cancel rather than a + write. Their handlers are already withheld without edit permission. */} {hasWorkflowColumns && onRunWorkflows && ( - + {runLabel} )} {hasWorkflowColumns && onRefreshWorkflows && ( - + {refreshLabel} )} {hasWorkflowColumns && onStopWorkflows && runningInSelectionCount > 0 && ( - + {stopLabel} @@ -158,7 +168,10 @@ export function ContextMenu({ Insert row below - 1} onSelect={onDuplicate}> + 1} + onSelect={onDuplicate} + > Duplicate row 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 481e31ab9cf..55fe0a045d1 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 @@ -558,6 +558,9 @@ export function TableGrid({ // requires the delete lock clear too — mirror that here or the affordance // stays live on an append-only table and only fails on click. const canDestroyColumn = canMutateSchema && !locks?.deleteLocked + // Duplicate inserts a full copied row in one shot, so unlike the blank-row + // paths it needs the insert lock only — it is valid on an append-only table. + const canInsertFullRow = userPermissions.canEdit && !locks?.insertLocked // Manual grid entry is "add an empty row, then type into its cells" — the // typing is an update. So a *useful* manual add needs BOTH insert and update // unlocked; on an append-only table (update locked) it would leave a blank @@ -4055,9 +4058,7 @@ export function TableGrid({ )} {!isLoadingTable && !isLoadingRows && userPermissions.canEdit && ( - onBlockedAction('add-row')} - /> + )} @@ -4095,6 +4096,7 @@ export function TableGrid({ workflowCellScoped={Boolean(contextMenuGroupId)} disableEdit={!canEditCell} disableInsert={!canManualAddRow} + disableDuplicate={!canInsertFullRow} disableDelete={!canDeleteRow} /> @@ -4104,7 +4106,7 @@ export function TableGrid({ rows={rows} columns={displayColumns} onSave={handleInlineSave} - canEdit={userPermissions.canEdit} + canEdit={canEditCell} scrollContainer={scrollRef.current} /> diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx index 24eabe598a9..58e43d15fab 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx @@ -621,7 +621,9 @@ export function Table({ label: 'Import CSV', icon: Upload, onClick: onRequestImportCsv, - disabled: userPermissions.canEdit !== true, + // An import always inserts, so the insert lock disables it outright + // rather than letting the dialog run to a server-side 423. + disabled: userPermissions.canEdit !== true || tableData.locks.insertLocked, }, { label: 'Export CSV', diff --git a/apps/sim/lib/table/workflow-groups/service.ts b/apps/sim/lib/table/workflow-groups/service.ts index c36a3d64a0e..53a227c6959 100644 --- a/apps/sim/lib/table/workflow-groups/service.ts +++ b/apps/sim/lib/table/workflow-groups/service.ts @@ -287,8 +287,11 @@ export async function updateWorkflowGroup( const { updatedTable, added, remappedColumnIds, newOutputs, previousAutoRun } = await withLockedTable(data.tableId, async (table, trx) => { - // May drop/remap output columns and strip their row data — destructive. - assertColumnDestructive(table) + // Any group patch edits the schema; the stronger destructive assert is + // applied below, only once we know this patch actually drops or remaps + // output columns (a rename / autoRun / mapping-only edit must not need + // the delete lock clear). + assertSchemaMutable(table) await setTableTxTimeouts(trx, { statementMs: 60_000 }) const schema = table.schema @@ -403,6 +406,11 @@ export async function updateWorkflowGroup( } const removedColumnIds = new Set(removed.map((o) => o.columnName)) + // Both paths strip values out of every row below, so they need the delete + // lock clear as well as the schema lock — same rule as a column drop. + if (removedColumnIds.size > 0 || remappedColumnIds.size > 0) { + assertColumnDestructive(table) + } let nextColumns = schema.columns .filter((c) => !removedColumnIds.has(getColumnId(c))) .map((c) => { From bbebfa0d577c69847372fb0cb8d6dd0ecaab862f Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Sat, 25 Jul 2026 00:15:26 -0700 Subject: [PATCH 05/15] fix(tables): keep column menu usable and let locks always be cleared - Pass the blocked-delete handler instead of undefined so ColumnOptionsMenu still mounts on a delete-locked table; withholding it hid the entire menu, including Insert column, which the delete lock must not affect - Restore ColumnHeaderMenu readOnly to permission-only: it swaps the header for a static label, which was also disabling pin, column-select and open-config - Assert the schema lock at the import-async enqueue when createColumns is set - Allow a locks PATCH that only clears locks even when the feature flag is off, and keep the settings entry reachable on a locked table, so flipping the kill switch can't strand a table with locks nobody can remove --- .../api/table/[tableId]/import-async/route.ts | 3 ++- apps/sim/app/api/table/[tableId]/route.ts | 7 +++++- .../components/table-grid/table-grid.tsx | 22 ++++++++++++++----- .../[workspaceId]/tables/[tableId]/table.tsx | 16 ++++++++++---- 4 files changed, 37 insertions(+), 11 deletions(-) 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 08a96062bd9..900e97d3a4d 100644 --- a/apps/sim/app/api/table/[tableId]/import-async/route.ts +++ b/apps/sim/app/api/table/[tableId]/import-async/route.ts @@ -10,7 +10,7 @@ import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { runTableImport, type TableImportPayload } from '@/lib/table/import-runner' import { markTableJobRunning, releaseJobClaim } from '@/lib/table/jobs/service' -import { assertRowDelete, assertRowInsert } from '@/lib/table/mutation-locks' +import { assertRowDelete, assertRowInsert, assertSchemaMutable } from '@/lib/table/mutation-locks' import { getUserSettings } from '@/lib/users/queries' import { accessError, checkAccess } from '@/app/api/table/utils' @@ -58,6 +58,7 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro // reports 423 here instead of holding the slot and failing inside the worker. assertRowInsert(table) if (mode === 'replace') assertRowDelete(table) + if (createColumns && createColumns.length > 0) assertSchemaMutable(table) const ext = fileName.split('.').pop()?.toLowerCase() if (ext !== 'csv' && ext !== 'tsv') { diff --git a/apps/sim/app/api/table/[tableId]/route.ts b/apps/sim/app/api/table/[tableId]/route.ts index ada92283e15..eef6f3459ec 100644 --- a/apps/sim/app/api/table/[tableId]/route.ts +++ b/apps/sim/app/api/table/[tableId]/route.ts @@ -142,7 +142,12 @@ export const PATCH = withRouteHandler( } if (validated.locks !== undefined) { - if (!(await isFeatureEnabled('table-locks'))) { + // With the flag off you may still CLEAR locks — otherwise flipping the + // kill switch would strand an already-locked table with no way to + // unlock it, while enforcement of those stored locks keeps running. + // Only turning a lock ON requires the feature to be enabled. + const isClearingOnly = Object.values(validated.locks).every((v) => v === false) + if (!isClearingOnly && !(await isFeatureEnabled('table-locks'))) { return NextResponse.json({ error: 'Table locks are not enabled' }, { status: 403 }) } const adminResult = await checkAccess(tableId, authResult.userId, 'admin') 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 55fe0a045d1..522d852775a 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 @@ -3815,9 +3815,18 @@ export function TableGrid({ onInsertRight={ canMutateSchema ? handleInsertColumnRight : undefined } - onDeleteColumn={canDestroyColumn ? handleDeleteColumn : undefined} + // Pass the blocked handler rather than `undefined`: + // `ColumnOptionsMenu` only mounts when all three + // handlers exist, so withholding this one would + // hide the whole menu — including Insert column, + // which a delete lock alone must not affect. + onDeleteColumn={ + canDestroyColumn ? handleDeleteColumn : handleBlockedDeleteColumn + } onDeleteGroup={ - canDestroyColumn ? handleDeleteWorkflowGroup : undefined + canDestroyColumn + ? handleDeleteWorkflowGroup + : handleBlockedDeleteColumn } onViewWorkflow={ workflowGroupById.get(g.groupId)?.type === 'enrichment' @@ -3880,9 +3889,12 @@ export function TableGrid({ key={column.key} column={column} colIndex={idx} - // Schema-locked disables the header menu (add/rename/delete - // column, drag-reorder); pin stays via `onPinToggle` below. - readOnly={!canMutateSchema} + // Permission-only: `readOnly` swaps the whole header for a + // static label, which would also kill pin, column-select and + // open-config — all metadata, not schema. The schema lock is + // enforced per-action instead (insert/delete below), and a + // rename attempt surfaces the server's 423 as a toast. + readOnly={!userPermissions.canEdit} isRenaming={columnRename.editingId === column.key} isColumnSelected={ isColumnSelection && diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx index 58e43d15fab..8d5350d7698 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx @@ -565,7 +565,10 @@ export function Table({ icon: Pencil, onClick: handleStartTableRename, }, - ...(userPermissions.canAdmin && tableLocksEnabled + // Reachable with the flag off when something is locked, so an + // admin can always clear locks (the route allows clearing). + ...(userPermissions.canAdmin && + (tableLocksEnabled || lockedNouns(tableData.locks).length > 0) ? [ { label: 'Lock settings', @@ -1040,9 +1043,14 @@ export function Table({ 0) + } onClose={() => setBlockedAction(null)} onOpenLockSettings={() => setShowLockSettings(true)} /> From 1cfd3c8af9744d9981b75ea86890e57ee7ca73b6 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Sat, 25 Jul 2026 00:30:56 -0700 Subject: [PATCH 06/15] fix(tables): scope the update-lock exemption and re-check locks per page - Make the workflow-output carve-out opt-in via `computedWrite`, set only by the cell-write path. It was caller-agnostic, so an ordinary API caller could PATCH a workflow-output column on an update-locked table - Re-read the lock before every page in the delete and update runners. The single worker-start check went stale immediately, so enabling a lock could not stop a job already deleting or overwriting rows. Committed pages still stay committed, exactly as with an explicit cancel. Import keeps its one-shot check: a half-imported table needs the deletes the lock now forbids - Route Insert column to the locked-action modal under a schema lock instead of leaving it live (regular header) or hiding the whole menu (group header) --- .../components/table-grid/table-grid.tsx | 29 ++++++--- apps/sim/lib/table/cell-write.test.ts | 2 +- apps/sim/lib/table/cell-write.ts | 4 +- apps/sim/lib/table/delete-runner.test.ts | 16 +++++ apps/sim/lib/table/delete-runner.ts | 58 +++++++++++------- apps/sim/lib/table/import-runner.ts | 3 + apps/sim/lib/table/mutation-locks.test.ts | 17 ++++-- apps/sim/lib/table/mutation-locks.ts | 25 ++++++-- apps/sim/lib/table/rows/service.ts | 8 ++- apps/sim/lib/table/update-runner.ts | 59 +++++++++++-------- 10 files changed, 155 insertions(+), 66 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx index 522d852775a..4dd85ce3a8c 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 @@ -1257,6 +1257,11 @@ export function TableGrid({ onBlockedActionRef.current('delete-column') }, []) + /** Stable stand-in for the insert-column handlers when the schema is locked. */ + const handleBlockedAddColumn = useCallback(() => { + onBlockedActionRef.current('add-column') + }, []) + const handleRowContextMenu = useCallback( (e: React.MouseEvent, row: TableRowType) => { setEditingCell(null) @@ -3811,15 +3816,17 @@ export function TableGrid({ onRunColumn={userPermissions.canEdit ? handleRunColumn : undefined} hasActiveFilter={Boolean(effectiveFilter)} selectedRowIds={selectedRowIds} - onInsertLeft={canMutateSchema ? handleInsertColumnLeft : undefined} + // Every locked action passes its blocked handler rather + // than `undefined`: `ColumnOptionsMenu` only mounts when + // all three handlers exist, so withholding one would hide + // the whole menu — and each item should explain the lock + // rather than silently vanish or fail with a 423 toast. + onInsertLeft={ + canMutateSchema ? handleInsertColumnLeft : handleBlockedAddColumn + } onInsertRight={ - canMutateSchema ? handleInsertColumnRight : undefined + canMutateSchema ? handleInsertColumnRight : handleBlockedAddColumn } - // Pass the blocked handler rather than `undefined`: - // `ColumnOptionsMenu` only mounts when all three - // handlers exist, so withholding this one would - // hide the whole menu — including Insert column, - // which a delete lock alone must not affect. onDeleteColumn={ canDestroyColumn ? handleDeleteColumn : handleBlockedDeleteColumn } @@ -3909,8 +3916,12 @@ export function TableGrid({ onRenameSubmit={columnRename.submitRename} onRenameCancel={columnRename.cancelRename} onColumnSelect={handleColumnSelect} - onInsertLeft={handleInsertColumnLeft} - onInsertRight={handleInsertColumnRight} + onInsertLeft={ + canMutateSchema ? handleInsertColumnLeft : handleBlockedAddColumn + } + onInsertRight={ + canMutateSchema ? handleInsertColumnRight : handleBlockedAddColumn + } onDeleteColumn={ canDestroyColumn ? handleDeleteColumn : handleBlockedDeleteColumn } diff --git a/apps/sim/lib/table/cell-write.test.ts b/apps/sim/lib/table/cell-write.test.ts index 42066e1a0cb..cd6eb08dbe7 100644 --- a/apps/sim/lib/table/cell-write.test.ts +++ b/apps/sim/lib/table/cell-write.test.ts @@ -141,7 +141,7 @@ describe('writeWorkflowGroupState', () => { }, TABLE, CONTEXT.requestId, - { dataWriteMode: 'patch' } + { dataWriteMode: 'patch', computedWrite: true } ) expect(mockAppendTableEvent).toHaveBeenCalledWith( expect.objectContaining({ diff --git a/apps/sim/lib/table/cell-write.ts b/apps/sim/lib/table/cell-write.ts index 15756b71603..d32b0c988f2 100644 --- a/apps/sim/lib/table/cell-write.ts +++ b/apps/sim/lib/table/cell-write.ts @@ -84,7 +84,9 @@ export async function writeWorkflowGroupState( }, table, requestId, - { dataWriteMode: 'patch' } + // `computedWrite` is what lets a workflow column keep populating on an + // update-locked table; the lock still covers user-authored columns. + { dataWriteMode: 'patch', computedWrite: true } ) } else { result = await db.transaction((trx) => diff --git a/apps/sim/lib/table/delete-runner.test.ts b/apps/sim/lib/table/delete-runner.test.ts index 18c6c92f02e..064722e4a83 100644 --- a/apps/sim/lib/table/delete-runner.test.ts +++ b/apps/sim/lib/table/delete-runner.test.ts @@ -91,6 +91,22 @@ describe('runTableDelete', () => { ) }) + it('stops mid-run when the delete lock is enabled between pages', async () => { + mockGetTableById + .mockResolvedValueOnce(table) + .mockResolvedValueOnce(table) + .mockResolvedValue({ ...table, locks: { ...UNLOCKED, deleteLocked: true } }) + mockSelectRowIdPage.mockResolvedValueOnce(['a', 'b']) + + await expect(runTableDelete(basePayload())).resolves.toBeUndefined() + + // First page committed before the lock landed; the second never runs. + expect(mockDeletePageByIds).toHaveBeenCalledTimes(1) + expect(mockDeletePageByIds).toHaveBeenCalledWith('tbl_1', 'ws_1', ['a', 'b'], expect.anything()) + expect(mockMarkJobCanceled).toHaveBeenCalledWith('tbl_1', 'job_1') + expect(mockMarkJobReady).not.toHaveBeenCalled() + }) + it('deletes every matching page then marks the job ready', async () => { mockSelectRowIdPage .mockResolvedValueOnce(['a', 'b']) diff --git a/apps/sim/lib/table/delete-runner.ts b/apps/sim/lib/table/delete-runner.ts index 27a382fe549..9de820da6bd 100644 --- a/apps/sim/lib/table/delete-runner.ts +++ b/apps/sim/lib/table/delete-runner.ts @@ -2,7 +2,7 @@ import { createLogger } from '@sim/logger' import { getErrorMessage, toError } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { truncate } from '@sim/utils/string' -import type { Filter } from '@/lib/table' +import type { Filter, TableDefinition } from '@/lib/table' import { TABLE_LIMITS, USER_TABLE_ROWS_SQL_NAME } from '@/lib/table/constants' import { appendTableEvent } from '@/lib/table/events' import { @@ -68,28 +68,32 @@ export async function runTableDelete(payload: TableDeletePayload): Promise const table = await getTableById(tableId, { includeArchived: true }) if (!table) throw new Error(`Delete target table ${tableId} not found`) - // A lock set between admission and this worker actually starting (queue - // delay, or a retry re-entering here) means nothing has been deleted yet — - // honor it and stop. Returning rather than throwing releases the job slot - // without burning this task's remaining `maxAttempts` on a deterministic - // failure. Past this point the run continues even if a lock lands - // mid-flight: committed pages are never rolled back, so aborting would - // leave the same half-done state as a cancel that nobody asked for — an - // admin stops it explicitly via `POST /api/table/[tableId]/job/cancel`. - let deleteProof: MutationProof<'delete'> - try { - deleteProof = assertRowDelete(table) - } catch (err) { - if (!(err instanceof TableLockedError)) throw err - logger.info(`[${requestId}] Delete job stopped — table was delete-locked before it started`, { - tableId, - jobId, - }) - await markJobCanceled(tableId, jobId) - void appendTableEvent({ kind: 'job', type: 'delete', tableId, jobId, status: 'canceled' }) - return + // Gate the run on the delete lock, then re-gate it before every page (see + // the loop below) so enabling the lock mid-job stops the deletion rather + // than merely preventing the next one. Returning rather than throwing + // releases the job slot without burning this task's remaining + // `maxAttempts` on a deterministic failure. + const stopIfLocked = async ( + fresh: TableDefinition, + processedSoFar: number + ): Promise | null> => { + try { + return assertRowDelete(fresh) + } catch (err) { + if (!(err instanceof TableLockedError)) throw err + logger.info(`[${requestId}] Delete job stopped — table is delete-locked`, { + tableId, + jobId, + processedSoFar, + }) + await markJobCanceled(tableId, jobId) + void appendTableEvent({ kind: 'job', type: 'delete', tableId, jobId, status: 'canceled' }) + return null + } } + if ((await stopIfLocked(table, 0)) === null) return + const filterClause = filter ? buildFilterClause(filter, USER_TABLE_ROWS_SQL_NAME, table.schema.columns) : undefined @@ -111,6 +115,16 @@ export async function runTableDelete(payload: TableDeletePayload): Promise const owns = await updateJobProgress(tableId, processed, jobId) if (!owns) throw new JobSupersededError() + // Re-read the lock state before every page. One indexed lookup per + // DELETE_PAGE_SIZE rows is cheap next to the page's delete, and it makes + // the delete lock a real stop button for a running job instead of a + // check that went stale the moment the worker started. Pages already + // committed stay committed — the same contract as an explicit cancel. + const current = await getTableById(tableId, { includeArchived: true }) + if (!current) throw new JobSupersededError() + const pageProof = await stopIfLocked(current, processed) + if (pageProof === null) return + const page = await selectRowIdPage({ tableId, workspaceId, @@ -126,7 +140,7 @@ export async function runTableDelete(payload: TableDeletePayload): Promise const toDelete = excluded.size > 0 ? page.filter((id) => !excluded.has(id)) : page if (toDelete.length > 0) { - processed += await deletePageByIds(tableId, workspaceId, toDelete, deleteProof) + processed += await deletePageByIds(tableId, workspaceId, toDelete, pageProof) } if ( diff --git a/apps/sim/lib/table/import-runner.ts b/apps/sim/lib/table/import-runner.ts index 9380ee0e88a..9c792891853 100644 --- a/apps/sim/lib/table/import-runner.ts +++ b/apps/sim/lib/table/import-runner.ts @@ -103,6 +103,9 @@ export async function runTableImport(payload: TableImportPayload): Promise // fails up front instead of after `deleteAllTableRows` has already wiped it. // (The sync replace path gets this for free from `replaceTableRowsWithTx`, // which asserts both in one place; this path deletes and inserts separately.) + // Checked once, unlike the delete/update runners which re-check per page: a + // lock landing mid-import should let the file finish, since a half-imported + // table has to be cleaned up with the very deletes the lock now forbids. assertRowInsert(table) if (mode === 'replace') assertRowDelete(table) diff --git a/apps/sim/lib/table/mutation-locks.test.ts b/apps/sim/lib/table/mutation-locks.test.ts index a51a7f00ba4..efeb58dd46a 100644 --- a/apps/sim/lib/table/mutation-locks.test.ts +++ b/apps/sim/lib/table/mutation-locks.test.ts @@ -75,19 +75,28 @@ describe('mutation-locks', () => { expect(() => assertRowUpdate(makeTable({ updateLocked: true }), [])).not.toThrow() }) - it('permits a patch touching only workflow-group output columns', () => { + it('permits a computed write touching only workflow-group output columns', () => { const table = makeTable({ updateLocked: true }, [ { id: 'col_wf', name: 'Enriched', type: 'string', workflowGroupId: 'grp_1' }, ]) - expect(() => assertRowUpdate(table, ['col_wf'])).not.toThrow() + expect(() => assertRowUpdate(table, ['col_wf'], { computedWrite: true })).not.toThrow() }) - it('blocks a patch touching a user-authored column', () => { + it('blocks an ordinary caller patching a workflow-group output column', () => { + const table = makeTable({ updateLocked: true }, [ + { id: 'col_wf', name: 'Enriched', type: 'string', workflowGroupId: 'grp_1' }, + ]) + expect(() => assertRowUpdate(table, ['col_wf'])).toThrow(TableLockedError) + }) + + it('blocks a computed write that also touches a user-authored column', () => { const table = makeTable({ updateLocked: true }, [ { id: 'col_wf', name: 'Enriched', type: 'string', workflowGroupId: 'grp_1' }, { id: 'col_user', name: 'Name', type: 'string' }, ]) - expect(() => assertRowUpdate(table, ['col_wf', 'col_user'])).toThrow(TableLockedError) + expect(() => assertRowUpdate(table, ['col_wf', 'col_user'], { computedWrite: true })).toThrow( + TableLockedError + ) }) }) diff --git a/apps/sim/lib/table/mutation-locks.ts b/apps/sim/lib/table/mutation-locks.ts index 856ae4d998d..9a5c6e59cbd 100644 --- a/apps/sim/lib/table/mutation-locks.ts +++ b/apps/sim/lib/table/mutation-locks.ts @@ -104,17 +104,23 @@ export function assertRowDelete(table: TableDefinition): MutationProof<'delete'> * executions-only write — cancellation, error/completed status stamps, and * usage-limit pre-stamp clears. These are not row edits; blocking them would * strand cells in `running` forever, so they always pass. - * - A patch that touches **only workflow-group output columns** is a computed - * write (enrichment / workflow column), not a user edit, so it passes even on - * an update-locked table. A patch touching any user-authored column is blocked. + * - A **computed write** (`computedWrite: true`) that touches only workflow-group + * output columns is the workflow/enrichment engine filling its own cells, not + * a user edit, so it passes. The opt-in is what makes this safe: it is set + * only by `cell-write.ts`, so an ordinary API caller cannot get the exemption + * by aiming a PATCH at a workflow output column. A computed write that + * touches any user-authored column is still blocked. */ export function assertRowUpdate( table: TableDefinition, - columnIds?: readonly string[] + columnIds?: readonly string[], + options: UpdateAssertOptions = {} ): MutationProof<'update'> { if (!table.locks?.updateLocked) return proofFor<'update'>() if (!columnIds || columnIds.length === 0) return proofFor<'update'>() - if (patchTouchesOnlyWorkflowColumns(table, columnIds)) return proofFor<'update'>() + if (options.computedWrite && patchTouchesOnlyWorkflowColumns(table, columnIds)) { + return proofFor<'update'>() + } logBlocked(table, 'update') throw new TableLockedError('update') } @@ -143,6 +149,15 @@ export function assertColumnDestructive(table: TableDefinition): void { } } +export interface UpdateAssertOptions { + /** + * Set only by the workflow/enrichment cell-write path. Unlocks the + * workflow-output carve-out in {@link assertRowUpdate}; every other caller + * leaves it unset and is held to the full update lock. + */ + computedWrite?: boolean +} + /** * True when every id in `columnIds` maps to a workflow-group output column in * the table's schema. Used by {@link assertRowUpdate} to exempt computed-cell diff --git a/apps/sim/lib/table/rows/service.ts b/apps/sim/lib/table/rows/service.ts index 29660a2181e..8b7fb61a42a 100644 --- a/apps/sim/lib/table/rows/service.ts +++ b/apps/sim/lib/table/rows/service.ts @@ -1174,6 +1174,12 @@ export interface UpdateRowOptions { * retaining the same merged-row validation and returned shape. */ dataWriteMode?: 'replace' | 'patch' + /** + * Marks the write as the workflow/enrichment engine filling its own output + * cells, which exempts it from the update lock. Set by `cell-write.ts` only — + * see {@link assertRowUpdate}. + */ + computedWrite?: boolean } export async function updateRow( @@ -1182,7 +1188,7 @@ export async function updateRow( requestId: string, options: UpdateRowOptions = {} ): Promise { - assertRowUpdate(table, patchColumnIds(data.data)) + assertRowUpdate(table, patchColumnIds(data.data), { computedWrite: options.computedWrite }) // Get existing row const existingRow = await getRowById(data.tableId, data.rowId, data.workspaceId) diff --git a/apps/sim/lib/table/update-runner.ts b/apps/sim/lib/table/update-runner.ts index ab026b28a66..4487a9bd545 100644 --- a/apps/sim/lib/table/update-runner.ts +++ b/apps/sim/lib/table/update-runner.ts @@ -2,7 +2,7 @@ import { createLogger } from '@sim/logger' import { getErrorMessage, toError } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { truncate } from '@sim/utils/string' -import type { Filter, RowData } from '@/lib/table' +import type { Filter, RowData, TableDefinition } from '@/lib/table' import { TABLE_LIMITS, USER_TABLE_ROWS_SQL_NAME } from '@/lib/table/constants' import { appendTableEvent } from '@/lib/table/events' import { @@ -74,29 +74,34 @@ export async function runTableUpdate(payload: TableUpdatePayload): Promise const table = await getTableById(tableId, { includeArchived: true }) if (!table) throw new Error(`Update target table ${tableId} not found`) - // A lock set between admission and this worker starting (queue delay, or a - // retry re-entering here) means nothing has been written yet — honor it and - // stop. Runs through `assertRowUpdate` rather than reading `updateLocked` - // directly so the enqueue site and the worker apply identical rules, - // including the workflow-column exemption: a patch touching only - // workflow-group outputs is allowed under an update lock, and cancelling it - // here would contradict the enqueue assert that just let it through. - // See `delete-runner.ts` for why a mid-flight lock does NOT abort a run - // that has already committed pages. - let updateProof: MutationProof<'update'> - try { - updateProof = assertRowUpdate(table, patchColumnIds(data)) - } catch (err) { - if (!(err instanceof TableLockedError)) throw err - logger.info(`[${requestId}] Update job stopped — table was update-locked before it started`, { - tableId, - jobId, - }) - await markJobCanceled(tableId, jobId) - void appendTableEvent({ kind: 'job', type: 'update', tableId, jobId, status: 'canceled' }) - return + // Gate the run on the update lock, then re-gate it before every page (see + // the loop below), so enabling the lock stops a job that is already + // running. Runs through `assertRowUpdate` rather than reading + // `updateLocked` directly so the enqueue site and the worker apply + // identical rules. This is a user-driven bulk patch, so it deliberately + // does not pass `computedWrite` — the workflow-output carve-out belongs to + // the cell-write path alone. + const stopIfLocked = async ( + fresh: TableDefinition, + processedSoFar: number + ): Promise | null> => { + try { + return assertRowUpdate(fresh, patchColumnIds(data)) + } catch (err) { + if (!(err instanceof TableLockedError)) throw err + logger.info(`[${requestId}] Update job stopped — table is update-locked`, { + tableId, + jobId, + processedSoFar, + }) + await markJobCanceled(tableId, jobId) + void appendTableEvent({ kind: 'job', type: 'update', tableId, jobId, status: 'canceled' }) + return null + } } + if ((await stopIfLocked(table, 0)) === null) return + const filterClause = buildFilterClause(filter, USER_TABLE_ROWS_SQL_NAME, table.schema.columns) if (!filterClause) throw new Error('Filter is required for bulk update') @@ -118,6 +123,14 @@ export async function runTableUpdate(payload: TableUpdatePayload): Promise const owns = await updateJobProgress(tableId, processed, jobId) if (!owns) throw new JobSupersededError() + // Re-read the lock before every page so enabling the update lock stops a + // running job, not just the next one — one indexed lookup per page of + // rows. Pages already applied stay applied, as with an explicit cancel. + const current = await getTableById(tableId, { includeArchived: true }) + if (!current) throw new JobSupersededError() + const pageProof = await stopIfLocked(current, processed) + if (pageProof === null) return + const page = await selectRowDataPage({ tableId, workspaceId, @@ -152,7 +165,7 @@ export async function runTableUpdate(payload: TableUpdatePayload): Promise workspaceId, page.map((r) => r.id), patchJson, - updateProof + pageProof ) if ( From 0f13ee206748755ae8f78a0aa76f60caee4f9772 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Sat, 25 Jul 2026 00:45:11 -0700 Subject: [PATCH 07/15] fix(tables): revalidate locks in-transaction and unbreak backfill + partial unlock - Re-assert the lock inside each page-write transaction, under the same advisory lock updateTableLocks takes, so check-then-write is atomic against a lock change. The per-page check alone still left the page-selection await between the assert and the write - Pass computedWrite from the backfill runner: batchUpdateRows is the workflow-output backfill path, so scoping the exemption had made rebuilding outputs 423 on an update-locked table while live cell writes still worked - Gate the feature flag on a lock actually going off->on rather than on an all-false payload. The settings UI always submits all four flags, so with the flag off an admin could not clear one lock while another stayed on - Hoist the lock-kind -> flag map to lib/table/types as TABLE_LOCK_FLAGS --- apps/sim/app/api/table/[tableId]/route.ts | 13 ++++-- apps/sim/hooks/use-table-undo.ts | 11 +---- apps/sim/lib/table/backfill-runner.ts | 5 +- apps/sim/lib/table/delete-runner.test.ts | 22 +++++++-- apps/sim/lib/table/delete-runner.ts | 47 +++++++++++++------ apps/sim/lib/table/rows/ordering.ts | 36 +++++++++++++- apps/sim/lib/table/rows/service.ts | 17 +++++-- apps/sim/lib/table/types.ts | 8 ++++ apps/sim/lib/table/update-runner.test.ts | 6 ++- apps/sim/lib/table/update-runner.ts | 57 ++++++++++++++++------- 10 files changed, 168 insertions(+), 54 deletions(-) diff --git a/apps/sim/app/api/table/[tableId]/route.ts b/apps/sim/app/api/table/[tableId]/route.ts index eef6f3459ec..5fe0f4add09 100644 --- a/apps/sim/app/api/table/[tableId]/route.ts +++ b/apps/sim/app/api/table/[tableId]/route.ts @@ -17,6 +17,7 @@ import { updateTableLocks, } from '@/lib/table' import { getWorkspaceTableLimits } from '@/lib/table/billing' +import { TABLE_LOCK_FLAGS, TABLE_LOCK_KINDS } from '@/lib/table/types' import { accessError, checkAccess, @@ -145,9 +146,15 @@ export const PATCH = withRouteHandler( // With the flag off you may still CLEAR locks — otherwise flipping the // kill switch would strand an already-locked table with no way to // unlock it, while enforcement of those stored locks keeps running. - // Only turning a lock ON requires the feature to be enabled. - const isClearingOnly = Object.values(validated.locks).every((v) => v === false) - if (!isClearingOnly && !(await isFeatureEnabled('table-locks'))) { + // Only a lock actually transitioning off→on needs the feature enabled; + // comparing against the stored state (rather than "every value is + // false") is what lets the settings UI, which always submits the full + // four-flag draft, clear one lock while another stays on. + const enablesALock = TABLE_LOCK_KINDS.some((kind) => { + const flag = TABLE_LOCK_FLAGS[kind] + return validated.locks?.[flag] === true && !table.locks[flag] + }) + if (enablesALock && !(await isFeatureEnabled('table-locks'))) { return NextResponse.json({ error: 'Table locks are not enabled' }, { status: 403 }) } const adminResult = await checkAccess(tableId, authResult.userId, 'admin') diff --git a/apps/sim/hooks/use-table-undo.ts b/apps/sim/hooks/use-table-undo.ts index 2cf6f907550..6d7346d6282 100644 --- a/apps/sim/hooks/use-table-undo.ts +++ b/apps/sim/hooks/use-table-undo.ts @@ -2,7 +2,7 @@ import { useCallback, useEffect, useRef } from 'react' import { toast } from '@sim/emcn' import { createLogger } from '@sim/logger' import { TABLE_LIMITS } from '@/lib/table/constants' -import type { TableLockKind, TableLocks } from '@/lib/table/types' +import { TABLE_LOCK_FLAGS, type TableLockKind, type TableLocks } from '@/lib/table/types' import { useAddTableColumn, useBatchCreateTableRows, @@ -56,13 +56,6 @@ function undoActionVerbs(action: TableUndoAction, direction: 'undo' | 'redo'): T } } -const LOCK_FLAG_BY_KIND: Record = { - insert: 'insertLocked', - update: 'updateLocked', - delete: 'deleteLocked', - schema: 'schemaLocked', -} - /** The first lock (if any) that would block replaying `action` in `direction`. */ function undoActionBlockedBy( action: TableUndoAction, @@ -70,7 +63,7 @@ function undoActionBlockedBy( locks: TableLocks | undefined ): TableLockKind | null { if (!locks) return null - return undoActionVerbs(action, direction).find((kind) => locks[LOCK_FLAG_BY_KIND[kind]]) ?? null + return undoActionVerbs(action, direction).find((kind) => locks[TABLE_LOCK_FLAGS[kind]]) ?? null } const BLOCKED_UNDO_MESSAGE: Record = { diff --git a/apps/sim/lib/table/backfill-runner.ts b/apps/sim/lib/table/backfill-runner.ts index b368e8ca0ad..d3e9946cfee 100644 --- a/apps/sim/lib/table/backfill-runner.ts +++ b/apps/sim/lib/table/backfill-runner.ts @@ -176,7 +176,10 @@ async function processBackfillPage(opts: { await batchUpdateRows( { tableId: table.id, updates, workspaceId: table.workspaceId, actorUserId }, table, - requestId + requestId, + // Every patched key is a workflow-group output column, so a backfill is a + // computed write and stays allowed on an update-locked table. + { computedWrite: true } ) return updates.length } diff --git a/apps/sim/lib/table/delete-runner.test.ts b/apps/sim/lib/table/delete-runner.test.ts index 064722e4a83..33f3f7cdc23 100644 --- a/apps/sim/lib/table/delete-runner.test.ts +++ b/apps/sim/lib/table/delete-runner.test.ts @@ -102,7 +102,13 @@ describe('runTableDelete', () => { // First page committed before the lock landed; the second never runs. expect(mockDeletePageByIds).toHaveBeenCalledTimes(1) - expect(mockDeletePageByIds).toHaveBeenCalledWith('tbl_1', 'ws_1', ['a', 'b'], expect.anything()) + expect(mockDeletePageByIds).toHaveBeenCalledWith( + 'tbl_1', + 'ws_1', + ['a', 'b'], + expect.anything(), + expect.any(Function) + ) expect(mockMarkJobCanceled).toHaveBeenCalledWith('tbl_1', 'job_1') expect(mockMarkJobReady).not.toHaveBeenCalled() }) @@ -120,14 +126,16 @@ describe('runTableDelete', () => { 'tbl_1', 'ws_1', ['a', 'b'], - expect.anything() + expect.anything(), + expect.any(Function) ) expect(mockDeletePageByIds).toHaveBeenNthCalledWith( 2, 'tbl_1', 'ws_1', ['c'], - expect.anything() + expect.anything(), + expect.any(Function) ) expect(mockMarkJobReady).toHaveBeenCalledWith('tbl_1', 'job_1') expect(mockAppendTableEvent).toHaveBeenCalledWith( @@ -157,7 +165,13 @@ describe('runTableDelete', () => { await runTableDelete(basePayload({ excludeRowIds: ['keep'] })) expect(mockDeletePageByIds).toHaveBeenCalledTimes(1) - expect(mockDeletePageByIds).toHaveBeenCalledWith('tbl_1', 'ws_1', ['x'], expect.anything()) + expect(mockDeletePageByIds).toHaveBeenCalledWith( + 'tbl_1', + 'ws_1', + ['x'], + expect.anything(), + expect.any(Function) + ) // Second page is queried after the last id of the first page (cursor advanced past 'keep'). expect(mockSelectRowIdPage).toHaveBeenNthCalledWith( 2, diff --git a/apps/sim/lib/table/delete-runner.ts b/apps/sim/lib/table/delete-runner.ts index 9de820da6bd..c8bb2d7ee47 100644 --- a/apps/sim/lib/table/delete-runner.ts +++ b/apps/sim/lib/table/delete-runner.ts @@ -13,6 +13,7 @@ import { updateJobProgress, } from '@/lib/table/jobs/service' import { assertRowDelete, type MutationProof, TableLockedError } from '@/lib/table/mutation-locks' +import type { DbTransaction } from '@/lib/table/planner' import { deletePageByIds, selectRowIdPage } from '@/lib/table/rows/ordering' import { getTableById } from '@/lib/table/service' import { buildFilterClause } from '@/lib/table/sql' @@ -73,6 +74,16 @@ export async function runTableDelete(payload: TableDeletePayload): Promise // than merely preventing the next one. Returning rather than throwing // releases the job slot without burning this task's remaining // `maxAttempts` on a deterministic failure. + const cancelForLock = async (processedSoFar: number): Promise => { + logger.info(`[${requestId}] Delete job stopped — table is delete-locked`, { + tableId, + jobId, + processedSoFar, + }) + await markJobCanceled(tableId, jobId) + void appendTableEvent({ kind: 'job', type: 'delete', tableId, jobId, status: 'canceled' }) + } + const stopIfLocked = async ( fresh: TableDefinition, processedSoFar: number @@ -81,19 +92,20 @@ export async function runTableDelete(payload: TableDeletePayload): Promise return assertRowDelete(fresh) } catch (err) { if (!(err instanceof TableLockedError)) throw err - logger.info(`[${requestId}] Delete job stopped — table is delete-locked`, { - tableId, - jobId, - processedSoFar, - }) - await markJobCanceled(tableId, jobId) - void appendTableEvent({ kind: 'job', type: 'delete', tableId, jobId, status: 'canceled' }) + await cancelForLock(processedSoFar) return null } } if ((await stopIfLocked(table, 0)) === null) return + // Runs inside each batch's transaction, under the same advisory lock the + // lock toggle holds, so no page can be written after a lock commits. + const revalidate = async (trx: DbTransaction) => { + const fresh = await getTableById(tableId, { tx: trx, includeArchived: true }) + if (fresh) assertRowDelete(fresh) + } + const filterClause = filter ? buildFilterClause(filter, USER_TABLE_ROWS_SQL_NAME, table.schema.columns) : undefined @@ -115,11 +127,11 @@ export async function runTableDelete(payload: TableDeletePayload): Promise const owns = await updateJobProgress(tableId, processed, jobId) if (!owns) throw new JobSupersededError() - // Re-read the lock state before every page. One indexed lookup per - // DELETE_PAGE_SIZE rows is cheap next to the page's delete, and it makes - // the delete lock a real stop button for a running job instead of a - // check that went stale the moment the worker started. Pages already - // committed stay committed — the same contract as an explicit cancel. + // Cheap early-out before selecting a page we may not be allowed to + // write. The authoritative gate is `revalidate` below, which re-asserts + // inside each batch transaction; this read only avoids the wasted page + // fetch. Pages already committed stay committed — the same contract as + // an explicit cancel. const current = await getTableById(tableId, { includeArchived: true }) if (!current) throw new JobSupersededError() const pageProof = await stopIfLocked(current, processed) @@ -140,7 +152,16 @@ export async function runTableDelete(payload: TableDeletePayload): Promise const toDelete = excluded.size > 0 ? page.filter((id) => !excluded.has(id)) : page if (toDelete.length > 0) { - processed += await deletePageByIds(tableId, workspaceId, toDelete, pageProof) + try { + processed += await deletePageByIds(tableId, workspaceId, toDelete, pageProof, revalidate) + } catch (err) { + if (!(err instanceof TableLockedError)) throw err + // A lock landed between batches. Batches already committed stay + // deleted; `processed` undercounts them, which only affects the + // final progress number on an already-canceled job. + await cancelForLock(processed) + return + } } if ( diff --git a/apps/sim/lib/table/rows/ordering.ts b/apps/sim/lib/table/rows/ordering.ts index 6530dcd87aa..62485b1e9e3 100644 --- a/apps/sim/lib/table/rows/ordering.ts +++ b/apps/sim/lib/table/rows/ordering.ts @@ -395,6 +395,32 @@ export async function selectRowDataPage(params: { return rows.map((r) => ({ id: r.id, data: r.data as RowData })) } +/** + * Re-verifies the table's locks from inside a write transaction. Supplied by + * the background runners; see {@link deletePageByIds}. + */ +export type MutationRevalidator = (trx: DbTransaction) => Promise + +/** + * Takes the table's schema advisory lock and runs `revalidate` inside the + * caller's transaction. The lock toggle (`updateTableLocks`) writes under the + * same key, so check-then-write becomes atomic with respect to a lock change: + * a lock committed before this call is seen and throws; one committed after it + * waits for this batch to finish. Without it, the caller's proof would only + * describe the lock state at some earlier point in the run. + */ +async function guardBatch( + trx: DbTransaction, + tableId: string, + revalidate: MutationRevalidator | undefined +): Promise { + if (!revalidate) return + await trx.execute( + sql`SELECT pg_advisory_xact_lock(hashtextextended(${`user_table_schema:${tableId}`}, 0))` + ) + await revalidate(trx) +} + /** * Deletes one page of rows for the async delete-job worker, committing each `DELETE_BATCH_SIZE` * chunk in its own short transaction. One statement per transaction bounds how long the @@ -410,13 +436,16 @@ export async function deletePageByIds( workspaceId: string, rowIds: string[], /** Proof the caller asserted the delete lock (see `mutation-locks.ts`). */ - _proof: MutationProof<'delete'> + _proof: MutationProof<'delete'>, + /** Re-asserts the lock inside each batch transaction. See {@link guardBatch}. */ + revalidate?: MutationRevalidator ): Promise { let deleted = 0 for (let i = 0; i < rowIds.length; i += TABLE_LIMITS.DELETE_BATCH_SIZE) { const batch = rowIds.slice(i, i + TABLE_LIMITS.DELETE_BATCH_SIZE) const rows = await db.transaction(async (trx) => { await setTableTxTimeouts(trx, { statementMs: 60_000 }) + await guardBatch(trx, tableId, revalidate) return trx .delete(userTableRows) .where( @@ -444,7 +473,9 @@ export async function updatePageByIds( rowIds: string[], patchJson: string, /** Proof the caller asserted the update lock (see `mutation-locks.ts`). */ - _proof: MutationProof<'update'> + _proof: MutationProof<'update'>, + /** Re-asserts the lock inside each batch transaction. See {@link guardBatch}. */ + revalidate?: MutationRevalidator ): Promise { const now = new Date() let updated = 0 @@ -452,6 +483,7 @@ export async function updatePageByIds( const batch = rowIds.slice(i, i + TABLE_LIMITS.UPDATE_BATCH_SIZE) const rows = await db.transaction(async (trx) => { await setTableTxTimeouts(trx, { statementMs: 60_000 }) + await guardBatch(trx, tableId, revalidate) return trx .update(userTableRows) .set({ data: sql`${userTableRows.data} || ${patchJson}::jsonb`, updatedAt: now }) diff --git a/apps/sim/lib/table/rows/service.ts b/apps/sim/lib/table/rows/service.ts index 8b7fb61a42a..2f68b404918 100644 --- a/apps/sim/lib/table/rows/service.ts +++ b/apps/sim/lib/table/rows/service.ts @@ -1532,6 +1532,14 @@ export async function updateRowsByFilter( } } +export interface BatchUpdateRowsOptions { + /** + * Marks the batch as workflow/enrichment output cells (the backfill runner), + * exempting it from the update lock. See {@link assertRowUpdate}. + */ + computedWrite?: boolean +} + /** * Updates multiple rows with per-row data in a single transaction. * Avoids the race condition of parallel update_row calls overwriting each other. @@ -1539,16 +1547,19 @@ export async function updateRowsByFilter( export async function batchUpdateRows( data: BatchUpdateByIdData, table: TableDefinition, - requestId: string + requestId: string, + options: BatchUpdateRowsOptions = {} ): Promise { if (data.updates.length === 0) { return { affectedCount: 0, affectedRowIds: [] } } - // Also the workflow-column backfill write path. + // Doubles as the workflow-output backfill write path, which passes + // `computedWrite` so a rebuild keeps working on an update-locked table. assertRowUpdate( table, - data.updates.flatMap((u) => patchColumnIds(u.data)) + data.updates.flatMap((u) => patchColumnIds(u.data)), + { computedWrite: options.computedWrite } ) const rowIds = data.updates.map((u) => u.rowId) diff --git a/apps/sim/lib/table/types.ts b/apps/sim/lib/table/types.ts index 420c5777348..95f6e29eafc 100644 --- a/apps/sim/lib/table/types.ts +++ b/apps/sim/lib/table/types.ts @@ -369,6 +369,14 @@ export interface TableLocks { deleteLocked: boolean } +/** Maps each verb to the {@link TableLocks} flag that guards it. */ +export const TABLE_LOCK_FLAGS: Record = { + schema: 'schemaLocked', + insert: 'insertLocked', + update: 'updateLocked', + delete: 'deleteLocked', +} + /** A fully-unlocked lock set — the state every new table is created in. */ export const UNLOCKED_TABLE_LOCKS: TableLocks = { schemaLocked: false, diff --git a/apps/sim/lib/table/update-runner.test.ts b/apps/sim/lib/table/update-runner.test.ts index ad1e3af3407..39fb914ec7a 100644 --- a/apps/sim/lib/table/update-runner.test.ts +++ b/apps/sim/lib/table/update-runner.test.ts @@ -125,7 +125,8 @@ describe('runTableUpdate', () => { 'ws_1', ['a', 'b'], expect.any(String), - expect.anything() + expect.anything(), + expect.any(Function) ) expect(mockUpdatePageByIds).toHaveBeenNthCalledWith( 2, @@ -133,7 +134,8 @@ describe('runTableUpdate', () => { 'ws_1', ['c'], expect.any(String), - expect.anything() + expect.anything(), + expect.any(Function) ) expect(mockMarkJobReady).toHaveBeenCalledWith('tbl_1', 'job_1') expect(mockAppendTableEvent).toHaveBeenCalledWith( diff --git a/apps/sim/lib/table/update-runner.ts b/apps/sim/lib/table/update-runner.ts index 4487a9bd545..8aceb2ff57a 100644 --- a/apps/sim/lib/table/update-runner.ts +++ b/apps/sim/lib/table/update-runner.ts @@ -18,6 +18,7 @@ import { patchColumnIds, TableLockedError, } from '@/lib/table/mutation-locks' +import type { DbTransaction } from '@/lib/table/planner' import { selectRowDataPage, updatePageByIds } from '@/lib/table/rows/ordering' import { getTableById } from '@/lib/table/service' import { buildFilterClause } from '@/lib/table/sql' @@ -81,6 +82,16 @@ export async function runTableUpdate(payload: TableUpdatePayload): Promise // identical rules. This is a user-driven bulk patch, so it deliberately // does not pass `computedWrite` — the workflow-output carve-out belongs to // the cell-write path alone. + const cancelForLock = async (processedSoFar: number): Promise => { + logger.info(`[${requestId}] Update job stopped — table is update-locked`, { + tableId, + jobId, + processedSoFar, + }) + await markJobCanceled(tableId, jobId) + void appendTableEvent({ kind: 'job', type: 'update', tableId, jobId, status: 'canceled' }) + } + const stopIfLocked = async ( fresh: TableDefinition, processedSoFar: number @@ -89,19 +100,20 @@ export async function runTableUpdate(payload: TableUpdatePayload): Promise return assertRowUpdate(fresh, patchColumnIds(data)) } catch (err) { if (!(err instanceof TableLockedError)) throw err - logger.info(`[${requestId}] Update job stopped — table is update-locked`, { - tableId, - jobId, - processedSoFar, - }) - await markJobCanceled(tableId, jobId) - void appendTableEvent({ kind: 'job', type: 'update', tableId, jobId, status: 'canceled' }) + await cancelForLock(processedSoFar) return null } } if ((await stopIfLocked(table, 0)) === null) return + // Runs inside each batch's transaction, under the same advisory lock the + // lock toggle holds, so no page can be written after a lock commits. + const revalidate = async (trx: DbTransaction) => { + const fresh = await getTableById(tableId, { tx: trx, includeArchived: true }) + if (fresh) assertRowUpdate(fresh, patchColumnIds(data)) + } + const filterClause = buildFilterClause(filter, USER_TABLE_ROWS_SQL_NAME, table.schema.columns) if (!filterClause) throw new Error('Filter is required for bulk update') @@ -123,9 +135,10 @@ export async function runTableUpdate(payload: TableUpdatePayload): Promise const owns = await updateJobProgress(tableId, processed, jobId) if (!owns) throw new JobSupersededError() - // Re-read the lock before every page so enabling the update lock stops a - // running job, not just the next one — one indexed lookup per page of - // rows. Pages already applied stay applied, as with an explicit cancel. + // Cheap early-out before selecting a page we may not be allowed to + // write. The authoritative gate is `revalidate` below, which re-asserts + // inside each batch transaction. Pages already applied stay applied, as + // with an explicit cancel. const current = await getTableById(tableId, { includeArchived: true }) if (!current) throw new JobSupersededError() const pageProof = await stopIfLocked(current, processed) @@ -160,13 +173,23 @@ export async function runTableUpdate(payload: TableUpdatePayload): Promise } } - processed += await updatePageByIds( - tableId, - workspaceId, - page.map((r) => r.id), - patchJson, - pageProof - ) + try { + processed += await updatePageByIds( + tableId, + workspaceId, + page.map((r) => r.id), + patchJson, + pageProof, + revalidate + ) + } catch (err) { + if (!(err instanceof TableLockedError)) throw err + // A lock landed between batches. Batches already committed stay + // applied; `processed` undercounts them, which only affects the final + // progress number on an already-canceled job. + await cancelForLock(processed) + return + } if ( processed - lastReported >= PROGRESS_INTERVAL_ROWS || From 6f48eb605eb61dd287f182e5696aa2ca1652d342 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Sat, 25 Jul 2026 00:59:22 -0700 Subject: [PATCH 08/15] fix(tables): guard import batches in-transaction and split paste by verb - Re-assert the insert lock inside each import batch's insert transaction, under the same advisory lock updateTableLocks takes. Reversing the earlier "let the file finish" call: an admin can lift the lock to clean up a partial import, so honouring it beats letting the rest of the file land - Gate paste per verb. Overwriting existing rows is an update and extending past the last row is a full-row insert, so a paste-append still works on an append-only table while an overwrite explains itself. Refuses the whole paste rather than applying half of it - Space opens the same row editor as double-click, so it now follows the update lock instead of filling in a form that 423s on save --- .../components/table-grid/table-grid.tsx | 27 ++++++++++++++++--- apps/sim/lib/table/import-data.ts | 15 ++++++++--- apps/sim/lib/table/import-runner.ts | 16 ++++++++--- apps/sim/lib/table/rows/ordering.ts | 2 +- 4 files changed, 48 insertions(+), 12 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx index 4dd85ce3a8c..2922644ee9a 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 @@ -572,6 +572,8 @@ export function TableGrid({ canEditCellRef.current = canEditCell const canManualAddRowRef = useRef(canManualAddRow) canManualAddRowRef.current = canManualAddRow + const canInsertFullRowRef = useRef(canInsertFullRow) + canInsertFullRowRef.current = canInsertFullRow // Read by the closure-free double-click handler to tell "locked" apart from // "no write permission" — only the former gets the explanation modal. const updateLockedRef = useRef(locks?.updateLocked) @@ -2346,6 +2348,12 @@ export function TableGrid({ if (e.key === ' ' && !e.shiftKey) { if (!canEditRef.current) return e.preventDefault() + // Space opens the same row editor as double-click, so it follows the + // update lock too — otherwise the form fills in and only 423s on save. + if (updateLockedRef.current) { + onBlockedActionRef.current('edit-cell') + return + } const row = currentRows[anchor.rowIndex] if (row) { onOpenRowModalRef.current(row) @@ -2840,10 +2848,7 @@ export function TableGrid({ const handlePaste = (e: ClipboardEvent) => { const tag = (e.target as HTMLElement).tagName if (tag === 'INPUT' || tag === 'TEXTAREA') return - // Paste overwrites the selected cells (an edit). Under an update lock we - // block it wholesale — the append-into-new-rows variant is a rarer path - // the user can still reach via the Add row button. - if (!canEditCellRef.current) return + if (!canEditRef.current) return const currentAnchor = selectionAnchorRef.current if (!currentAnchor || editingCellRef.current) return @@ -2899,6 +2904,20 @@ export function TableGrid({ } } + // A paste can do two different things at once: overwrite existing rows + // (an update) and extend past the last row (a full-row insert). Gate each + // against its own lock, and refuse the whole paste rather than applying + // half of it — so a full-row paste-append still works on an append-only + // table, while an overwrite on that same table explains itself. + if (updateBatch.length > 0 && !canEditCellRef.current) { + onBlockedActionRef.current('edit-cell') + return + } + if (createBatchRows.length > 0 && !canInsertFullRowRef.current) { + onBlockedActionRef.current('add-row') + return + } + if (updateBatch.length > 0) { batchUpdateRef.current({ updates: updateBatch }) pushUndoRef.current({ diff --git a/apps/sim/lib/table/import-data.ts b/apps/sim/lib/table/import-data.ts index 06a2f291fcb..72ca1ebdb8d 100644 --- a/apps/sim/lib/table/import-data.ts +++ b/apps/sim/lib/table/import-data.ts @@ -13,7 +13,11 @@ import { assertRowCapacity, notifyTableRowUsage } from '@/lib/table/billing' import { CSV_MAX_BATCH_SIZE } from '@/lib/table/import' import { assertRowDelete, assertRowInsert, assertSchemaMutable } from '@/lib/table/mutation-locks' import { nKeysBetween } from '@/lib/table/order-key' -import { acquireRowOrderLock } from '@/lib/table/rows/ordering' +import { + acquireRowOrderLock, + guardBatch, + type MutationRevalidator, +} from '@/lib/table/rows/ordering' import { batchInsertRowsWithTx, replaceTableRowsWithTx } from '@/lib/table/rows/service' import { addTableColumnsWithTx, auditTableColumnsAdded } from '@/lib/table/service' import type { @@ -61,7 +65,9 @@ export interface BulkImportBatch { export async function bulkInsertImportBatch( data: BulkImportBatch, table: TableDefinition, - requestId: string + requestId: string, + /** Re-asserts the insert lock inside the write transaction. See {@link guardBatch}. */ + revalidate?: MutationRevalidator ): Promise<{ inserted: number; lastOrderKey: string | null }> { assertRowInsert(table) @@ -107,7 +113,10 @@ export async function bulkInsertImportBatch( ...(data.userId ? { createdBy: data.userId } : {}), })) - await db.insert(userTableRows).values(rowsToInsert) + await db.transaction(async (trx) => { + await guardBatch(trx, data.tableId, revalidate) + await trx.insert(userTableRows).values(rowsToInsert) + }) logger.info(`[${requestId}] Bulk-imported ${rowsToInsert.length} rows into table ${data.tableId}`) return { inserted: rowsToInsert.length, diff --git a/apps/sim/lib/table/import-runner.ts b/apps/sim/lib/table/import-runner.ts index 9c792891853..29379d70ad1 100644 --- a/apps/sim/lib/table/import-runner.ts +++ b/apps/sim/lib/table/import-runner.ts @@ -29,6 +29,7 @@ import { } from '@/lib/table/import-data' import { markJobFailed, markJobReady, updateJobProgress } from '@/lib/table/jobs/service' import { assertRowDelete, assertRowInsert } from '@/lib/table/mutation-locks' +import type { DbTransaction } from '@/lib/table/planner' import { nextImportStartOrderKey, nextImportStartPosition } from '@/lib/table/rows/ordering' import { getTableById } from '@/lib/table/service' import { deleteFile, downloadFileStream, headObject } from '@/lib/uploads/core/storage-service' @@ -103,12 +104,18 @@ export async function runTableImport(payload: TableImportPayload): Promise // fails up front instead of after `deleteAllTableRows` has already wiped it. // (The sync replace path gets this for free from `replaceTableRowsWithTx`, // which asserts both in one place; this path deletes and inserts separately.) - // Checked once, unlike the delete/update runners which re-check per page: a - // lock landing mid-import should let the file finish, since a half-imported - // table has to be cleaned up with the very deletes the lock now forbids. assertRowInsert(table) if (mode === 'replace') assertRowDelete(table) + // Re-asserted inside every batch's insert transaction, under the same + // advisory lock `updateTableLocks` writes with, so enabling the insert lock + // mid-import stops it at the next batch instead of letting the rest of the + // file through. Rows already committed stay — as with an explicit cancel. + const revalidateInsert = async (trx: DbTransaction) => { + const fresh = await getTableById(tableId, { tx: trx, includeArchived: true }) + if (fresh) assertRowInsert(fresh) + } + // Total byte size for the progress estimate — a cheap HEAD, no download. May be null on // the local dev provider, in which case the bar stays indeterminate (rows still show). const totalBytes = (await headObject(fileKey, 'workspace'))?.size ?? 0 @@ -244,7 +251,8 @@ export async function runTableImport(payload: TableImportPayload): Promise afterOrderKey: lastOrderKey, }, { ...table, schema }, - requestId + requestId, + revalidateInsert ) notifyTableRowUsage({ workspaceId, diff --git a/apps/sim/lib/table/rows/ordering.ts b/apps/sim/lib/table/rows/ordering.ts index 62485b1e9e3..54deb2b116e 100644 --- a/apps/sim/lib/table/rows/ordering.ts +++ b/apps/sim/lib/table/rows/ordering.ts @@ -409,7 +409,7 @@ export type MutationRevalidator = (trx: DbTransaction) => Promise * waits for this batch to finish. Without it, the caller's proof would only * describe the lock state at some earlier point in the run. */ -async function guardBatch( +export async function guardBatch( trx: DbTransaction, tableId: string, revalidate: MutationRevalidator | undefined From 7a6f376a7468ddecf8f0fce2e0c22ca0342ce4fe Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Sat, 25 Jul 2026 01:06:12 -0700 Subject: [PATCH 09/15] fix(tables): guard the remaining import writes and explain blocked Shift+Enter - Route the replace-mode wipe, the inferred-schema write and createColumns through the same guardBatch transaction as the batch inserts, so a delete or schema lock committed while the file is downloading or being sampled is seen instead of the job-start snapshot - Shift+Enter takes the manual-add path, so it now opens the lock modal like the Add row button instead of returning silently --- .../components/table-grid/table-grid.tsx | 9 ++++- apps/sim/lib/table/import-data.ts | 37 +++++++++++++------ apps/sim/lib/table/import-runner.ts | 24 ++++++++++-- 3 files changed, 54 insertions(+), 16 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx index 2922644ee9a..b058846742a 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 @@ -2305,7 +2305,14 @@ export function TableGrid({ const totalRows = currentRows.length if (e.shiftKey && e.key === 'Enter') { - if (!canManualAddRowRef.current) return + if (!canEditRef.current) return + // Same manual-add path as the Add row button, so it owes the same + // explanation rather than silently doing nothing on a locked table. + if (!canManualAddRowRef.current) { + e.preventDefault() + onBlockedActionRef.current('add-row') + return + } const row = currentRows[anchor.rowIndex] if (!row) return e.preventDefault() diff --git a/apps/sim/lib/table/import-data.ts b/apps/sim/lib/table/import-data.ts index 72ca1ebdb8d..f27b190881f 100644 --- a/apps/sim/lib/table/import-data.ts +++ b/apps/sim/lib/table/import-data.ts @@ -125,9 +125,16 @@ export async function bulkInsertImportBatch( } /** Deletes every row of a table (set-based; the statement-level trigger zeroes `row_count`). */ -export async function deleteAllTableRows(table: TableDefinition): Promise { +export async function deleteAllTableRows( + table: TableDefinition, + /** Re-asserts the delete lock inside the write transaction. See {@link guardBatch}. */ + revalidate?: MutationRevalidator +): Promise { assertRowDelete(table) - await db.delete(userTableRows).where(eq(userTableRows.tableId, table.id)) + await db.transaction(async (trx) => { + await guardBatch(trx, table.id, revalidate) + await trx.delete(userTableRows).where(eq(userTableRows.tableId, table.id)) + }) } /** @@ -138,11 +145,14 @@ export async function addImportColumns( table: TableDefinition, additions: { name: string; type: string }[], requestId: string, - actingUserId?: string + actingUserId?: string, + /** Re-asserts the schema lock inside the write transaction. See {@link guardBatch}. */ + revalidate?: MutationRevalidator ): Promise { - const updated = await db.transaction((trx) => - addTableColumnsWithTx(trx, table, additions, requestId) - ) + const updated = await db.transaction(async (trx) => { + await guardBatch(trx, table.id, revalidate) + return addTableColumnsWithTx(trx, table, additions, requestId) + }) auditTableColumnsAdded( table, additions.map((c) => c.name), @@ -154,13 +164,18 @@ export async function addImportColumns( /** Overwrites a table's schema during an import (used when inferring columns from the file). */ export async function setTableSchemaForImport( table: TableDefinition, - schema: TableSchema + schema: TableSchema, + /** Re-asserts the schema lock inside the write transaction. See {@link guardBatch}. */ + revalidate?: MutationRevalidator ): Promise { assertSchemaMutable(table) - await db - .update(userTableDefinitions) - .set({ schema, updatedAt: new Date() }) - .where(eq(userTableDefinitions.id, table.id)) + await db.transaction(async (trx) => { + await guardBatch(trx, table.id, revalidate) + await trx + .update(userTableDefinitions) + .set({ schema, updatedAt: new Date() }) + .where(eq(userTableDefinitions.id, table.id)) + }) } /** diff --git a/apps/sim/lib/table/import-runner.ts b/apps/sim/lib/table/import-runner.ts index 29379d70ad1..ff1836fab73 100644 --- a/apps/sim/lib/table/import-runner.ts +++ b/apps/sim/lib/table/import-runner.ts @@ -28,7 +28,7 @@ import { setTableSchemaForImport, } from '@/lib/table/import-data' import { markJobFailed, markJobReady, updateJobProgress } from '@/lib/table/jobs/service' -import { assertRowDelete, assertRowInsert } from '@/lib/table/mutation-locks' +import { assertRowDelete, assertRowInsert, assertSchemaMutable } from '@/lib/table/mutation-locks' import type { DbTransaction } from '@/lib/table/planner' import { nextImportStartOrderKey, nextImportStartPosition } from '@/lib/table/rows/ordering' import { getTableById } from '@/lib/table/service' @@ -115,6 +115,16 @@ export async function runTableImport(payload: TableImportPayload): Promise const fresh = await getTableById(tableId, { tx: trx, includeArchived: true }) if (fresh) assertRowInsert(fresh) } + /** Same guard for the replace-mode wipe, which lands before the first batch. */ + const revalidateDelete = async (trx: DbTransaction) => { + const fresh = await getTableById(tableId, { tx: trx, includeArchived: true }) + if (fresh) assertRowDelete(fresh) + } + /** Same guard for the inferred-schema write and `createColumns`. */ + const revalidateSchema = async (trx: DbTransaction) => { + const fresh = await getTableById(tableId, { tx: trx, includeArchived: true }) + if (fresh) assertSchemaMutable(fresh) + } // Total byte size for the progress estimate — a cheap HEAD, no download. May be null on // the local dev provider, in which case the bar stays indeterminate (rows still show). @@ -178,7 +188,7 @@ export async function runTableImport(payload: TableImportPayload): Promise // the same ids). schema = withGeneratedColumnIds({ columns: inferred.columns.map(normalizeColumn) }) headerToColumn = inferred.headerToColumn - await setTableSchemaForImport(table, schema) + await setTableSchemaForImport(table, schema, revalidateSchema) return } @@ -207,7 +217,13 @@ export async function runTableImport(payload: TableImportPayload): Promise additions.push({ name: columnName, type: inferColumnType(sample.map((r) => r[header])) }) updatedMapping[header] = columnName } - const updated = await addImportColumns(table, additions, requestId, userId) + const updated = await addImportColumns( + table, + additions, + requestId, + userId, + revalidateSchema + ) targetSchema = updated.schema effectiveMapping = updatedMapping } @@ -223,7 +239,7 @@ export async function runTableImport(payload: TableImportPayload): Promise // Replace deletes existing rows only after schema/mapping validation passes, so an // invalid or empty file fails the import with the old rows still intact (a mid-stream // insert failure after this point leaves a partial replace — replace is destructive). - if (mode === 'replace') await deleteAllTableRows(table) + if (mode === 'replace') await deleteAllTableRows(table, revalidateDelete) } const flush = async (rows: Record[]) => { From 62779c81b8637d7936e2c8d5a94823a0164027b8 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Sat, 25 Jul 2026 01:23:04 -0700 Subject: [PATCH 10/15] improvement(tables): surface blocked lock actions as a toast, not a modal - Replace TableLockedModal with a warning toast carrying a "Lock settings" action button for admins. Being told you can't edit shouldn't cost a dismiss click, and the button still routes admins straight to the panel - Dedupe by id so repeated attempts on a locked cell replace one notice instead of stacking a column of them - Move the copy into lock-copy.ts alongside the rest of the lock vocabulary and tighten it for toast length --- .../tables/[tableId]/components/index.ts | 1 - .../components/table-grid/table-grid.tsx | 2 +- .../components/table-locked-modal/index.ts | 1 - .../table-locked-modal/table-locked-modal.tsx | 137 ------------------ .../tables/[tableId]/lock-copy.ts | 65 ++++++++- .../[workspaceId]/tables/[tableId]/table.tsx | 71 ++++++--- 6 files changed, 113 insertions(+), 164 deletions(-) delete mode 100644 apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-locked-modal/index.ts delete mode 100644 apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-locked-modal/table-locked-modal.tsx diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/index.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/index.ts index bddfca9fc7b..97bdf334395 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/index.ts @@ -10,5 +10,4 @@ export * from './sidebar-fields' export * from './table-action-bar' export * from './table-filter' export * from './table-grid' -export * from './table-locked-modal' export * from './workflow-sidebar' 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 b058846742a..d4ccc27abd0 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 @@ -21,6 +21,7 @@ import type { import { getColumnId } from '@/lib/table/column-keys' import { TABLE_LIMITS } from '@/lib/table/constants' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' +import type { BlockedTableAction } from '@/app/workspace/[workspaceId]/tables/[tableId]/lock-copy' import { useTimezone } from '@/hooks/queries/general-settings' import { useAddTableColumn, @@ -50,7 +51,6 @@ import type { ColumnConfig } from '../column-config-sidebar' import { ContextMenu } from '../context-menu' import { NewColumnDropdown } from '../new-column-dropdown' import { resolveSelectOptions } from '../select-field' -import type { BlockedTableAction } from '../table-locked-modal' import type { WorkflowConfig } from '../workflow-sidebar' import { ExpandedCellPopover } from './cells' import { ADD_COL_WIDTH, COL_WIDTH, SELECTION_TINT_BG } from './constants' diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-locked-modal/index.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-locked-modal/index.ts deleted file mode 100644 index bc1e7292c94..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-locked-modal/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { type BlockedTableAction, TableLockedModal } from './table-locked-modal' diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-locked-modal/table-locked-modal.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-locked-modal/table-locked-modal.tsx deleted file mode 100644 index 9d20bc32ecf..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-locked-modal/table-locked-modal.tsx +++ /dev/null @@ -1,137 +0,0 @@ -'use client' - -import { - ChipConfirmModal, - ChipModal, - ChipModalBody, - ChipModalFooter, - ChipModalHeader, -} from '@sim/emcn' -import { Lock } from '@sim/emcn/icons' -import type { TableLocks } from '@/lib/table' -import { lockedNouns } from '@/app/workspace/[workspaceId]/tables/[tableId]/lock-copy' - -/** - * Why the locked-table modal opened. `'status'` is the informational case (the - * header lock chip); the rest are actions the user just tried and couldn't do. - */ -export type BlockedTableAction = 'add-row' | 'add-column' | 'delete-column' | 'edit-cell' | 'status' - -/** - * Copy for the action the user attempted. Explains what is blocked and — for - * the append-only manual-entry case — what to do instead, since that one is - * blocked by the *update* lock rather than the insert lock. - */ -function describe(action: BlockedTableAction, locks: TableLocks): { title: string; text: string } { - switch (action) { - case 'add-row': - if (locks.insertLocked) { - return { - title: 'Adding rows is locked', - text: 'New rows cannot be added to this table until an admin unlocks it.', - } - } - return { - title: 'This table is append-only', - text: 'Rows here cannot be edited after they are added, so typing a new row in the grid is unavailable. Add rows by importing a CSV, or from the API, a workflow, or Sim.', - } - case 'add-column': - return { - title: 'Changing columns is locked', - text: 'Columns cannot be added, renamed, retyped, or removed until an admin unlocks it.', - } - case 'delete-column': - // Reachable with the schema lock off but the delete lock on — removing a - // column clears its value from every row, so it needs both. - return locks.schemaLocked - ? { - title: 'Changing columns is locked', - text: 'Columns cannot be added, renamed, retyped, or removed until an admin unlocks it.', - } - : { - title: 'Deleting columns is locked', - text: 'Removing a column also deletes its value from every row, so it is blocked while this table is delete-locked.', - } - case 'edit-cell': - return { - title: 'Editing rows is locked', - text: 'Existing cell values cannot be changed until an admin unlocks it. Workflow and enrichment columns still populate on their own.', - } - case 'status': { - const nouns = lockedNouns(locks) - return { - title: 'Table locks', - text: - nouns.length > 0 - ? `An admin has locked ${nouns.join(', ')} on this table.` - : 'Nothing is locked on this table.', - } - } - } -} - -interface TableLockedModalProps { - /** The attempted action, or `null` when closed. */ - action: BlockedTableAction | null - locks: TableLocks - canAdmin: boolean - onClose: () => void - /** Opens the lock settings panel; only offered to admins. */ - onOpenLockSettings: () => void -} - -/** - * Explains why a table mutation is unavailable. Shown instead of silently - * disabling the control, so the user learns the table is locked rather than - * assuming the UI is broken — and admins get a direct route to the settings. - */ -export function TableLockedModal({ - action, - locks, - canAdmin, - onClose, - onOpenLockSettings, -}: TableLockedModalProps) { - if (!action) return null - const { title, text } = describe(action, locks) - - // Non-admins have nothing to act on, so this is a notice, not a decision — a - // confirm modal would render two buttons that both just dismiss. - if (!canAdmin) { - return ( - !next && onClose()} srTitle={title}> - - {title} - - -

{text}

-
- -
- ) - } - - return ( - !next && onClose()} - title={title} - icon={Lock} - text={text} - dismissLabel='Close' - confirm={{ - label: 'Lock settings', - // Navigational, not destructive — the confirm slot defaults to red. - variant: 'primary', - onClick: () => { - onClose() - onOpenLockSettings() - }, - }} - /> - ) -} diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/lock-copy.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/lock-copy.ts index fe3cd62435e..882e7413c37 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/lock-copy.ts +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/lock-copy.ts @@ -1,6 +1,6 @@ /** * Single source of truth for lock vocabulary shared by the lock settings modal, - * the locked-action modal, and the table header chip. Kept out of + * the blocked-action toast, and the table header chip. Kept out of * `lib/table/mutation-locks.ts` — that module is server-tainted (importing it * from a client component pulls `next/headers` into the browser bundle). */ @@ -65,3 +65,66 @@ export function describeLocks(locks: TableLocks): { name: string; detail: string } return { name: 'Locked', detail: `${locked.join(', ')} locked.` } } + +/** + * Why a locked-table notice was raised. `'status'` is the informational case + * (a non-admin clicking the header lock chip); the rest are actions the user + * just tried and couldn't do. + */ +export type BlockedTableAction = 'add-row' | 'add-column' | 'delete-column' | 'edit-cell' | 'status' + +/** + * Copy for the action the user attempted. Explains what is blocked and — for + * the append-only manual-entry case — what to do instead, since that one is + * blocked by the *update* lock rather than the insert lock. + */ +export function describeBlockedAction( + action: BlockedTableAction, + locks: TableLocks +): { title: string; text: string } { + switch (action) { + case 'add-row': + if (locks.insertLocked) { + return { + title: 'Adding rows is locked', + text: 'No new rows can be added until an admin unlocks this table.', + } + } + return { + title: 'This table is append-only', + text: 'Rows can’t be edited once added, so typing one into the grid is unavailable. Import a CSV, or add rows from the API, a workflow, or Sim.', + } + case 'add-column': + return { + title: 'Changing columns is locked', + text: 'Columns can’t be added, renamed, retyped, or removed until an admin unlocks this table.', + } + case 'delete-column': + // Reachable with the schema lock off but the delete lock on — removing a + // column clears its value from every row, so it needs both. + return locks.schemaLocked + ? { + title: 'Changing columns is locked', + text: 'Columns can’t be added, renamed, retyped, or removed until an admin unlocks this table.', + } + : { + title: 'Deleting columns is locked', + text: 'Removing a column deletes its value from every row, so it’s blocked while deleting is locked.', + } + case 'edit-cell': + return { + title: 'Editing rows is locked', + text: 'Existing cell values can’t be changed until an admin unlocks this table. Workflow and enrichment columns still populate.', + } + case 'status': { + const nouns = lockedNouns(locks) + return { + title: 'Table locks', + text: + nouns.length > 0 + ? `An admin has locked ${nouns.join(', ')} on this table.` + : 'Nothing is locked on this table.', + } + } + } +} diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx index 8d5350d7698..006c74c8307 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx @@ -44,7 +44,6 @@ import { useSettingsNavigation } from '@/hooks/use-settings-navigation' import { useLogDetailsUIStore } from '@/stores/logs/store' import type { DeletedRowSnapshot } from '@/stores/table/types' import { - type BlockedTableAction, type ColumnConfig, ColumnConfigSidebar, EnrichmentDetails, @@ -57,14 +56,18 @@ import { TableActionBar, TableFilter, TableGrid, - TableLockedModal, type WorkflowConfig, WorkflowSidebar, } from './components' import { COLUMN_SIDEBAR_WIDTH } from './components/table-grid/constants' import { COLUMN_TYPE_ICONS } from './components/table-grid/headers' import { useTable, useTableEventStream } from './hooks' -import { describeLocks, lockedNouns } from './lock-copy' +import { + type BlockedTableAction, + describeBlockedAction, + describeLocks, + lockedNouns, +} from './lock-copy' import { DEFAULT_TABLE_DETAIL_SORT_DIRECTION, tableDetailParsers, @@ -83,6 +86,9 @@ const logger = createLogger('Table') */ const tableLocksEnabled = isTruthy(getEnv('NEXT_PUBLIC_TABLE_LOCKS')) +/** Blocked-action toasts carry a button, so they linger past the 5s default. */ +const BLOCKED_TOAST_MS = 8000 + interface TableProps { /** When set, the table renders without its page header / breadcrumbs / page-level * options bar. Used by the mothership chat panel to embed a table inline. */ @@ -169,7 +175,9 @@ export function Table({ const [slideout, dispatch] = useReducer(slideoutReducer, { kind: 'none' }) const [showDeleteTableConfirm, setShowDeleteTableConfirm] = useState(false) const [showLockSettings, setShowLockSettings] = useState(false) - const [blockedAction, setBlockedAction] = useState(null) + // Id of the last blocked-action toast, so a user who keeps typing into a + // locked cell replaces one notice rather than stacking a column of them. + const blockedToastIdRef = useRef(null) const [isImportCsvOpen, setIsImportCsvOpen] = useState(false) const [editingRow, setEditingRow] = useState(null) const [deletingRows, setDeletingRows] = useState([]) @@ -602,6 +610,38 @@ export function Table({ ] ) + // An admin can always reach the settings on a locked table — clearing locks + // stays allowed with the flag off, so the kill switch can't strand one. With + // the flag off and nothing locked there is nothing to change, so the toast is + // a plain notice with no action. + const canOpenLockSettings = + userPermissions.canAdmin === true && + (tableLocksEnabled || (tableData ? lockedNouns(tableData.locks).length > 0 : false)) + + /** + * Explains why a table mutation is unavailable. A toast rather than a modal: + * being told you can't edit shouldn't cost a dismiss click, and admins still + * get a direct route to the settings via the action button. + */ + const showBlockedToast = useCallback( + (action: BlockedTableAction) => { + if (!tableData) return + if (blockedToastIdRef.current) toast.dismiss(blockedToastIdRef.current) + const { title, text } = describeBlockedAction(action, tableData.locks) + blockedToastIdRef.current = toast.warning(title, { + description: text, + ...(canOpenLockSettings + ? { + action: { label: 'Lock settings', onClick: () => setShowLockSettings(true) }, + // An action would otherwise pin the toast open until dismissed. + duration: BLOCKED_TOAST_MS, + } + : {}), + }) + }, + [tableData, canOpenLockSettings] + ) + const headerActions = useMemo(() => { if (!tableData) return undefined const anyLocked = lockedNouns(tableData.locks).length > 0 @@ -616,7 +656,7 @@ export function Table({ label: lockLabel, icon: Lock, onClick: () => - userPermissions.canAdmin ? setShowLockSettings(true) : setBlockedAction('status'), + userPermissions.canAdmin ? setShowLockSettings(true) : showBlockedToast('status'), }, ] : []), @@ -641,6 +681,7 @@ export function Table({ userPermissions.canAdmin, handleExportCsv, onRequestImportCsv, + showBlockedToast, ]) // Adding a column is a schema change. The trigger stays visible when the @@ -651,7 +692,7 @@ export function Table({ trigger='header' disabled={false} blocked={!canMutateSchema} - onBlocked={() => setBlockedAction('add-column')} + onBlocked={() => showBlockedToast('add-column')} onPickType={handleAddColumnOfType} onPickWorkflow={handleAddWorkflowColumn} onPickEnrichment={onOpenEnrichments} @@ -775,7 +816,7 @@ export function Table({ tableId={tableId} embedded={embedded} locks={tableData?.locks} - onBlockedAction={setBlockedAction} + onBlockedAction={showBlockedToast} sidebarReservedPx={sidebarReservedPx} onOpenColumnConfig={onOpenColumnConfig} onOpenWorkflowConfig={onOpenWorkflowConfig} @@ -1039,22 +1080,6 @@ export function Table({ locks={tableData.locks} /> )} - {tableData && ( - 0) - } - onClose={() => setBlockedAction(null)} - onOpenLockSettings={() => setShowLockSettings(true)} - /> - )} ) } From ed0d9f558b65095b7586bf0bd597daa0a362b85b Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Sat, 25 Jul 2026 01:31:42 -0700 Subject: [PATCH 11/15] fix(tables): don't show the update-lock notice to users without write access MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Double-click checked the update lock before any permission check, so a read-only member on a locked table got "Editing rows is locked" — misleading (the lock isn't why they can't edit) and it swallowed the expanded-cell viewer, which is a legitimate read-only affordance. --- .../tables/[tableId]/components/table-grid/table-grid.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx index d4ccc27abd0..15653f322a6 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 @@ -2117,8 +2117,10 @@ export function TableGrid({ // Double-click means "edit this cell". On an update-locked table, say so // rather than opening the expanded viewer — which looks like an editor - // that silently refuses to save. - if (updateLockedRef.current) { + // that silently refuses to save. Only for users who could otherwise edit: + // without write access the lock isn't why they can't, and they still get + // the read-only expanded viewer below. + if (canEditRef.current && updateLockedRef.current) { onBlockedActionRef.current('edit-cell') return } From 468097ae3c67a73c15167befba90043ed71e7f76 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Sat, 25 Jul 2026 01:53:39 -0700 Subject: [PATCH 12/15] improvement(tables): trim the update-lock toast copy --- .../app/workspace/[workspaceId]/tables/[tableId]/lock-copy.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/lock-copy.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/lock-copy.ts index 882e7413c37..3a50a4bd487 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/lock-copy.ts +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/lock-copy.ts @@ -114,7 +114,7 @@ export function describeBlockedAction( case 'edit-cell': return { title: 'Editing rows is locked', - text: 'Existing cell values can’t be changed until an admin unlocks this table. Workflow and enrichment columns still populate.', + text: 'Existing cell values can’t be changed until an admin unlocks this table.', } case 'status': { const nouns = lockedNouns(locks) From 1aa94242756b7af72951bb792cbfe75d27bb9b9b Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Sat, 25 Jul 2026 02:06:42 -0700 Subject: [PATCH 13/15] fix(tables): map append-import locks to 423 and stop conflating locks with permissions - The sync append branch returns instead of rethrowing, so the outer catch's mapper never saw a TableLockedError and every lock violation became a 500. Map it in that catch, with a regression test (replace mode already rethrows) - Stop mounting the workflow-group column menu for users without edit access: passing the blocked handlers unconditionally made it appear for read-only members and report a lock even on an unlocked table - Enter/F2 now raises the same lock notice as double-click and Space instead of silently doing nothing - guardBatch returns the freshly-read definition so addTableColumnsWithTx asserts live state; a schema lock cleared mid-import no longer fails the createColumns step. The snapshot pre-asserts now only run as the fallback for callers that pass no revalidator - Append-only no longer labels a table whose schema is also locked, which claimed columns were mutable when they weren't - Import dialog withholds Replace on a delete-locked table and create-column on a schema-locked one, instead of offering a configuration that only 423s --- .../api/table/[tableId]/import/route.test.ts | 21 +++++++++++ .../app/api/table/[tableId]/import/route.ts | 6 +++ .../components/table-grid/table-grid.tsx | 37 +++++++++++++++---- .../tables/[tableId]/lock-copy.ts | 4 +- .../import-csv-dialog/import-csv-dialog.tsx | 23 +++++++++--- apps/sim/lib/table/delete-runner.ts | 1 + apps/sim/lib/table/import-data.ts | 19 +++++++--- apps/sim/lib/table/import-runner.ts | 3 ++ apps/sim/lib/table/rows/ordering.ts | 13 ++++--- apps/sim/lib/table/update-runner.ts | 1 + 10 files changed, 105 insertions(+), 23 deletions(-) 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 0333d7fb4df..baf8c313a4f 100644 --- a/apps/sim/app/api/table/[tableId]/import/route.test.ts +++ b/apps/sim/app/api/table/[tableId]/import/route.test.ts @@ -31,6 +31,7 @@ vi.mock('@sim/utils/id', () => ({ vi.mock('@/app/api/table/utils', async () => { const { NextResponse } = await import('next/server') + const { TableLockedError } = await import('@/lib/table/mutation-locks') return { checkAccess: mockCheckAccess, accessError: (result: { status: number }) => { @@ -38,6 +39,10 @@ vi.mock('@/app/api/table/utils', async () => { return NextResponse.json({ error: message }, { status: result.status }) }, csvProxyBodyCapResponse: () => null, + tableLockErrorResponse: (error: unknown) => + error instanceof TableLockedError + ? NextResponse.json({ error: error.message, lock: error.lock }, { status: 423 }) + : null, multipartErrorResponse: (error: { code: string; message: string }) => NextResponse.json( { error: error.message }, @@ -74,6 +79,7 @@ vi.mock('@/lib/table/billing', () => ({ limit >= 0 && current + added > limit, })) +import { TableLockedError } from '@/lib/table/mutation-locks' import { POST } from '@/app/api/table/[tableId]/import/route' function createCsvFile(contents: string, name = 'data.csv', type = 'text/csv'): File { @@ -377,6 +383,21 @@ describe('POST /api/table/[tableId]/import', () => { expect(data.data?.insertedCount).toBe(0) }) + it('maps a lock violation from importAppendRows to 423, not 500', async () => { + // The append branch returns instead of rethrowing, so it must map the lock + // error itself — the outer catch's mapper never sees it. + mockImportAppendRows.mockRejectedValueOnce(new TableLockedError('insert')) + const response = await callPost( + createFormData(createCsvFile('name,age\nAlice,30'), { mode: 'append' }) + ) + expect(response.status).toBe(423) + const data = await response.json() + expect(data.lock).toBe('insert') + // A `details` array would make the client treat it as a validation error + // and swallow the toast. + expect(data.details).toBeUndefined() + }) + it('accepts TSV files', async () => { const response = await callPost( createFormData( diff --git a/apps/sim/app/api/table/[tableId]/import/route.ts b/apps/sim/app/api/table/[tableId]/import/route.ts index 984c1457f43..7a9802442cc 100644 --- a/apps/sim/app/api/table/[tableId]/import/route.ts +++ b/apps/sim/app/api/table/[tableId]/import/route.ts @@ -337,6 +337,12 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro }, }) } catch (err) { + // This branch returns rather than rethrowing, so the outer catch's + // mapper is unreachable from here — map the lock error first or a 423 + // degrades into a generic 500 (replace mode rethrows and maps fine). + const lockError = tableLockErrorResponse(err) + if (lockError) return lockError + const message = toError(err).message logger.warn(`[${requestId}] Append failed for table ${tableId}`, { total: coerced.length, 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 15653f322a6..f2400a4ae5b 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 @@ -2337,8 +2337,15 @@ export function TableGrid({ } if (e.key === 'Enter' || e.key === 'F2') { - if (!canEditCellRef.current) return + if (!canEditRef.current) return e.preventDefault() + // The primary keyboard edit path — same lock notice as double-click and + // Space, rather than a keypress that silently does nothing. + if (updateLockedRef.current) { + onBlockedActionRef.current('edit-cell') + return + } + if (!canEditCellRef.current) return const col = cols[anchor.colIndex] if (!col) return @@ -3850,18 +3857,32 @@ export function TableGrid({ // the whole menu — and each item should explain the lock // rather than silently vanish or fail with a 423 toast. onInsertLeft={ - canMutateSchema ? handleInsertColumnLeft : handleBlockedAddColumn + !userPermissions.canEdit + ? undefined + : canMutateSchema + ? handleInsertColumnLeft + : handleBlockedAddColumn } onInsertRight={ - canMutateSchema ? handleInsertColumnRight : handleBlockedAddColumn + !userPermissions.canEdit + ? undefined + : canMutateSchema + ? handleInsertColumnRight + : handleBlockedAddColumn } onDeleteColumn={ - canDestroyColumn ? handleDeleteColumn : handleBlockedDeleteColumn + !userPermissions.canEdit + ? undefined + : canDestroyColumn + ? handleDeleteColumn + : handleBlockedDeleteColumn } onDeleteGroup={ - canDestroyColumn - ? handleDeleteWorkflowGroup - : handleBlockedDeleteColumn + !userPermissions.canEdit + ? undefined + : canDestroyColumn + ? handleDeleteWorkflowGroup + : handleBlockedDeleteColumn } onViewWorkflow={ workflowGroupById.get(g.groupId)?.type === 'enrichment' @@ -3944,6 +3965,8 @@ export function TableGrid({ onRenameSubmit={columnRename.submitRename} onRenameCancel={columnRename.cancelRename} onColumnSelect={handleColumnSelect} + // Required props here, and the menu is already + // suppressed for non-editors by `readOnly`. onInsertLeft={ canMutateSchema ? handleInsertColumnLeft : handleBlockedAddColumn } diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/lock-copy.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/lock-copy.ts index 3a50a4bd487..359bcd25178 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/lock-copy.ts +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/lock-copy.ts @@ -60,7 +60,9 @@ export function describeLocks(locks: TableLocks): { name: string; detail: string if (locked.length === LOCK_FIELDS.length) { return { name: 'Read-only', detail: 'no one can change this table’s rows or columns.' } } - if (!locks.insertLocked && locks.updateLocked && locks.deleteLocked) { + // Only the exact three-lock shape is Append-only — with the schema locked too + // the chip would claim columns are mutable when they aren't. + if (!locks.insertLocked && locks.updateLocked && locks.deleteLocked && !locks.schemaLocked) { return { name: 'Append-only', detail: 'rows can be added, but not edited or deleted.' } } return { name: 'Locked', detail: `${locked.join(', ')} locked.` } 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 6ea241ab22f..739b3448eb3 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 @@ -175,10 +175,16 @@ export function ImportCsvDialog({ resetState() } + // Replace deletes every existing row and creating columns is a schema change, + // so each needs its own lock clear. Withholding them here keeps the dialog + // from offering a configuration the server can only answer with a 423. + const canReplace = !table.locks?.deleteLocked + const canCreateColumns = !table.locks?.schemaLocked + const columnOptions: ComboboxOption[] = useMemo(() => { const options: ComboboxOption[] = [ { label: 'Do not import', value: SKIP_VALUE }, - { label: '+ Create new column', value: CREATE_VALUE }, + ...(canCreateColumns ? [{ label: '+ Create new column', value: CREATE_VALUE }] : []), ] for (const col of table.schema.columns) { options.push({ @@ -187,7 +193,7 @@ export function ImportCsvDialog({ }) } return options - }, [table.schema.columns]) + }, [table.schema.columns, canCreateColumns]) async function handleFileSelected(file: File) { const ext = file.name.split('.').pop()?.toLowerCase() @@ -257,6 +263,7 @@ export function ImportCsvDialog({ function handleModeChange(value: string) { setSubmitError(null) + if (value === 'replace' && !canReplace) return setMode(value as CsvImportMode) } @@ -307,7 +314,8 @@ export function ImportCsvDialog({ async function handleSubmit() { if (!parsed || !canSubmit) return setSubmitError(null) - const createColumns = createHeaders.size > 0 ? [...createHeaders] : undefined + const createColumns = + canCreateColumns && createHeaders.size > 0 ? [...createHeaders] : undefined // Large files can't be POSTed through the server (request-body cap) — upload them // straight to storage and import in the background instead. Seed the header tray and @@ -426,14 +434,19 @@ export function ImportCsvDialog({ Append - Replace all rows + {canReplace && Replace all rows} {skipCount > 0 && (
-
diff --git a/apps/sim/lib/table/delete-runner.ts b/apps/sim/lib/table/delete-runner.ts index c8bb2d7ee47..a10a9b309fb 100644 --- a/apps/sim/lib/table/delete-runner.ts +++ b/apps/sim/lib/table/delete-runner.ts @@ -104,6 +104,7 @@ export async function runTableDelete(payload: TableDeletePayload): Promise const revalidate = async (trx: DbTransaction) => { const fresh = await getTableById(tableId, { tx: trx, includeArchived: true }) if (fresh) assertRowDelete(fresh) + return fresh ?? undefined } const filterClause = filter diff --git a/apps/sim/lib/table/import-data.ts b/apps/sim/lib/table/import-data.ts index f27b190881f..b58ab2edffa 100644 --- a/apps/sim/lib/table/import-data.ts +++ b/apps/sim/lib/table/import-data.ts @@ -69,7 +69,9 @@ export async function bulkInsertImportBatch( /** Re-asserts the insert lock inside the write transaction. See {@link guardBatch}. */ revalidate?: MutationRevalidator ): Promise<{ inserted: number; lastOrderKey: string | null }> { - assertRowInsert(table) + // Superseded by the in-tx revalidation when one is supplied; asserting + // the caller's snapshot too would reject a since-cleared lock. + if (!revalidate) assertRowInsert(table) for (let i = 0; i < data.rows.length; i++) { const sizeValidation = validateRowSize(data.rows[i]) @@ -130,7 +132,9 @@ export async function deleteAllTableRows( /** Re-asserts the delete lock inside the write transaction. See {@link guardBatch}. */ revalidate?: MutationRevalidator ): Promise { - assertRowDelete(table) + // Superseded by the in-tx revalidation when one is supplied; asserting + // the caller's snapshot too would reject a since-cleared lock. + if (!revalidate) assertRowDelete(table) await db.transaction(async (trx) => { await guardBatch(trx, table.id, revalidate) await trx.delete(userTableRows).where(eq(userTableRows.tableId, table.id)) @@ -150,8 +154,11 @@ export async function addImportColumns( revalidate?: MutationRevalidator ): Promise { const updated = await db.transaction(async (trx) => { - await guardBatch(trx, table.id, revalidate) - return addTableColumnsWithTx(trx, table, additions, requestId) + // `addTableColumnsWithTx` re-asserts the schema lock, so hand it the + // freshly-read definition — asserting the caller's snapshot would reject a + // lock that has since been cleared. + const fresh = await guardBatch(trx, table.id, revalidate) + return addTableColumnsWithTx(trx, fresh ?? table, additions, requestId) }) auditTableColumnsAdded( table, @@ -168,7 +175,9 @@ export async function setTableSchemaForImport( /** Re-asserts the schema lock inside the write transaction. See {@link guardBatch}. */ revalidate?: MutationRevalidator ): Promise { - assertSchemaMutable(table) + // Superseded by the in-tx revalidation when one is supplied; asserting + // the caller's snapshot too would reject a since-cleared lock. + if (!revalidate) assertSchemaMutable(table) await db.transaction(async (trx) => { await guardBatch(trx, table.id, revalidate) await trx diff --git a/apps/sim/lib/table/import-runner.ts b/apps/sim/lib/table/import-runner.ts index ff1836fab73..93ae112d415 100644 --- a/apps/sim/lib/table/import-runner.ts +++ b/apps/sim/lib/table/import-runner.ts @@ -114,16 +114,19 @@ export async function runTableImport(payload: TableImportPayload): Promise const revalidateInsert = async (trx: DbTransaction) => { const fresh = await getTableById(tableId, { tx: trx, includeArchived: true }) if (fresh) assertRowInsert(fresh) + return fresh ?? undefined } /** Same guard for the replace-mode wipe, which lands before the first batch. */ const revalidateDelete = async (trx: DbTransaction) => { const fresh = await getTableById(tableId, { tx: trx, includeArchived: true }) if (fresh) assertRowDelete(fresh) + return fresh ?? undefined } /** Same guard for the inferred-schema write and `createColumns`. */ const revalidateSchema = async (trx: DbTransaction) => { const fresh = await getTableById(tableId, { tx: trx, includeArchived: true }) if (fresh) assertSchemaMutable(fresh) + return fresh ?? undefined } // Total byte size for the progress estimate — a cheap HEAD, no download. May be null on diff --git a/apps/sim/lib/table/rows/ordering.ts b/apps/sim/lib/table/rows/ordering.ts index 54deb2b116e..b5e700d3e75 100644 --- a/apps/sim/lib/table/rows/ordering.ts +++ b/apps/sim/lib/table/rows/ordering.ts @@ -15,7 +15,7 @@ import type { MutationProof } from '@/lib/table/mutation-locks' import { keyBetween, nKeysBetween } from '@/lib/table/order-key' import { type DbExecutor, type DbTransaction, withSeqscanOff } from '@/lib/table/planner' import { setTableTxTimeouts } from '@/lib/table/tx' -import type { RowData } from '@/lib/table/types' +import type { RowData, TableDefinition } from '@/lib/table/types' /** * Starting `position` for an append import — `max(position) + 1`, or 0 when empty. Read once, @@ -399,7 +399,7 @@ export async function selectRowDataPage(params: { * Re-verifies the table's locks from inside a write transaction. Supplied by * the background runners; see {@link deletePageByIds}. */ -export type MutationRevalidator = (trx: DbTransaction) => Promise +export type MutationRevalidator = (trx: DbTransaction) => Promise /** * Takes the table's schema advisory lock and runs `revalidate` inside the @@ -408,17 +408,20 @@ export type MutationRevalidator = (trx: DbTransaction) => Promise * a lock committed before this call is seen and throws; one committed after it * waits for this batch to finish. Without it, the caller's proof would only * describe the lock state at some earlier point in the run. + * + * Returns the freshly-read definition so tx-bound helpers can act on live state + * instead of the caller's snapshot. */ export async function guardBatch( trx: DbTransaction, tableId: string, revalidate: MutationRevalidator | undefined -): Promise { - if (!revalidate) return +): Promise { + if (!revalidate) return undefined await trx.execute( sql`SELECT pg_advisory_xact_lock(hashtextextended(${`user_table_schema:${tableId}`}, 0))` ) - await revalidate(trx) + return revalidate(trx) } /** diff --git a/apps/sim/lib/table/update-runner.ts b/apps/sim/lib/table/update-runner.ts index 8aceb2ff57a..25a93eb2fd8 100644 --- a/apps/sim/lib/table/update-runner.ts +++ b/apps/sim/lib/table/update-runner.ts @@ -112,6 +112,7 @@ export async function runTableUpdate(payload: TableUpdatePayload): Promise const revalidate = async (trx: DbTransaction) => { const fresh = await getTableById(tableId, { tx: trx, includeArchived: true }) if (fresh) assertRowUpdate(fresh, patchColumnIds(data)) + return fresh ?? undefined } const filterClause = buildFilterClause(filter, USER_TABLE_ROWS_SQL_NAME, table.schema.columns) From fca76b0ce7c0409b1a26292688d984f5fa50b83a Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Sat, 25 Jul 2026 02:17:49 -0700 Subject: [PATCH 14/15] fix(tables): revalidate sync imports under the advisory lock, explain every blocked key - The sync append/replace paths asserted only the request-start snapshot, so a lock committed while the CSV was parsed still let the write through. Both now re-read under the schema advisory lock at the top of their own transaction, taken before acquireRowOrderLock so the order stays advisory -> rows_pos -> definitions - Delete/Backspace, Cmd+D, typeahead and cut raised no notice on an update-locked table; they now explain the lock, and stay silent for users without write access - The multipart import fetch threw a plain Error, dropping the 423 status, so the lock self-heal never ran and the stale detail cache survived. It throws ApiClientError now and both import-into-table hooks call handleTableLockRejection - Force append at submit when the delete lock landed while the dialog was open with Replace already selected --- .../components/table-grid/table-grid.tsx | 35 ++++++++++++++++--- .../import-csv-dialog/import-csv-dialog.tsx | 9 +++-- apps/sim/hooks/queries/tables.ts | 15 ++++++-- apps/sim/lib/table/import-data.ts | 33 ++++++++++++++--- 4 files changed, 76 insertions(+), 16 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx index f2400a4ae5b..2ee8fecd4b9 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 @@ -2269,7 +2269,12 @@ export function TableGrid({ !rowSelectionIsEmpty(rowSelectionRef.current) ) { if (editingCellRef.current) return - if (!canEditCellRef.current) return + if (!canEditCellRef.current) { + // Explain the lock rather than no-op, but only for users who + // could otherwise edit — for the rest it is a permission limit. + if (canEditRef.current) onBlockedActionRef.current('edit-cell') + return + } e.preventDefault() const rowSel = rowSelectionRef.current void (async () => { @@ -2479,7 +2484,12 @@ export function TableGrid({ if ((e.metaKey || e.ctrlKey) && e.key === 'd') { e.preventDefault() - if (!canEditCellRef.current) return + if (!canEditCellRef.current) { + // Explain the lock rather than no-op, but only for users who + // could otherwise edit — for the rest it is a permission limit. + if (canEditRef.current) onBlockedActionRef.current('edit-cell') + return + } const sel = computeNormalizedSelection(anchor, selectionFocusRef.current) if (!sel || sel.startRow === sel.endRow) return const sourceRow = currentRows[sel.startRow] @@ -2513,7 +2523,12 @@ export function TableGrid({ } if (e.key === 'Delete' || e.key === 'Backspace') { - if (!canEditCellRef.current) return + if (!canEditCellRef.current) { + // Explain the lock rather than no-op, but only for users who + // could otherwise edit — for the rest it is a permission limit. + if (canEditRef.current) onBlockedActionRef.current('edit-cell') + return + } e.preventDefault() const sel = computeNormalizedSelection(anchor, selectionFocusRef.current) if (!sel) return @@ -2574,7 +2589,12 @@ export function TableGrid({ } if (e.key.length === 1 && !e.metaKey && !e.ctrlKey && !e.altKey) { - if (!canEditCellRef.current) return + if (!canEditCellRef.current) { + // Explain the lock rather than no-op, but only for users who + // could otherwise edit — for the rest it is a permission limit. + if (canEditRef.current) onBlockedActionRef.current('edit-cell') + return + } const col = cols[anchor.colIndex] // Workflow-output cells are editable: the user can override the // workflow's value if they want. Booleans toggle on space/click — @@ -2777,7 +2797,12 @@ export function TableGrid({ const tag = (e.target as HTMLElement).tagName if (tag === 'INPUT' || tag === 'TEXTAREA') return if (editingCellRef.current) return - if (!canEditCellRef.current) return + if (!canEditCellRef.current) { + // Explain the lock rather than no-op, but only for users who + // could otherwise edit — for the rest it is a permission limit. + if (canEditRef.current) onBlockedActionRef.current('edit-cell') + return + } const rowSel = rowSelectionRef.current const cols = columnsRef.current 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 739b3448eb3..e306aeac52d 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 @@ -314,6 +314,9 @@ export function ImportCsvDialog({ async function handleSubmit() { if (!parsed || !canSubmit) return setSubmitError(null) + // Hiding the Replace control doesn't clear an already-selected `mode`, so a + // delete lock landing while the dialog is open would still submit replace. + const effectiveMode: CsvImportMode = canReplace ? mode : 'append' const createColumns = canCreateColumns && createHeaders.size > 0 ? [...createHeaders] : undefined @@ -334,7 +337,7 @@ export function ImportCsvDialog({ workspaceId, tableId: table.id, file: parsed.file, - mode, + mode: effectiveMode, mapping, createColumns, onProgress: (percent) => { @@ -365,12 +368,12 @@ export function ImportCsvDialog({ workspaceId, tableId: table.id, file: parsed.file, - mode, + mode: effectiveMode, mapping, createColumns, }) const data = result.data - if (mode === 'append') { + if (effectiveMode === 'append') { toast.success(`Imported ${data?.insertedCount ?? 0} rows into "${table.name}"`) } else { toast.success( diff --git a/apps/sim/hooks/queries/tables.ts b/apps/sim/hooks/queries/tables.ts index 84d9dcc13ee..aff122b6d34 100644 --- a/apps/sim/hooks/queries/tables.ts +++ b/apps/sim/hooks/queries/tables.ts @@ -17,6 +17,7 @@ import { } from '@tanstack/react-query' import { useRouter } from 'next/navigation' import { + ApiClientError, extractValidationIssues, isApiClientError, isValidationError, @@ -1578,7 +1579,13 @@ export function useUploadCsvToTable() { if (!response.ok) { const data = await response.json().catch(() => ({})) - throw new Error(data.error || 'CSV import failed') + // Carry the status: a plain Error drops it, and the 423 self-heal below + // keys off `error.status`. + throw new ApiClientError({ + status: response.status, + body: data, + message: data.error || 'CSV import failed', + }) } return response.json() @@ -1714,7 +1721,8 @@ export function useImportCsvIntoTableAsync() { }) return response.data }, - onError: (error) => { + onError: (error, variables) => { + if (handleTableLockRejection(error, queryClient, variables.tableId)) return logger.error('Failed to start async CSV import:', error) toast.error(error.message, { duration: 5000 }) }, @@ -1793,7 +1801,8 @@ export function useImportCsvIntoTable() { return response.json() }, - onError: (error) => { + onError: (error, variables) => { + if (handleTableLockRejection(error, queryClient, variables.tableId)) return logger.error('Failed to import CSV into table:', error) toast.error(error.message, { duration: 5000 }) }, diff --git a/apps/sim/lib/table/import-data.ts b/apps/sim/lib/table/import-data.ts index b58ab2edffa..84664e077b2 100644 --- a/apps/sim/lib/table/import-data.ts +++ b/apps/sim/lib/table/import-data.ts @@ -13,13 +13,14 @@ import { assertRowCapacity, notifyTableRowUsage } from '@/lib/table/billing' import { CSV_MAX_BATCH_SIZE } from '@/lib/table/import' import { assertRowDelete, assertRowInsert, assertSchemaMutable } from '@/lib/table/mutation-locks' import { nKeysBetween } from '@/lib/table/order-key' +import type { DbTransaction } from '@/lib/table/planner' import { acquireRowOrderLock, guardBatch, type MutationRevalidator, } from '@/lib/table/rows/ordering' import { batchInsertRowsWithTx, replaceTableRowsWithTx } from '@/lib/table/rows/service' -import { addTableColumnsWithTx, auditTableColumnsAdded } from '@/lib/table/service' +import { addTableColumnsWithTx, auditTableColumnsAdded, getTableById } from '@/lib/table/service' import type { ReplaceRowsResult, RowData, @@ -187,6 +188,28 @@ export async function setTableSchemaForImport( }) } +/** + * Re-reads the table under its schema advisory lock inside the caller's + * transaction. The sync import paths own their transaction rather than taking a + * revalidator, so they refresh here: a lock committed while the CSV was being + * parsed must be visible to the asserts in `addTableColumnsWithTx` / + * `batchInsertRowsWithTx` / `replaceTableRowsWithTx`, which all read the + * definition they are handed. + * + * Taken before `acquireRowOrderLock` so the order stays advisory → rows_pos → + * definitions, matching every other advisory-lock holder. + */ +async function refreshUnderLock( + trx: DbTransaction, + table: TableDefinition +): Promise { + const fresh = await guardBatch(trx, table.id, async (tx) => { + const latest = await getTableById(table.id, { tx, includeArchived: true }) + return latest ?? undefined + }) + return fresh ?? table +} + /** * Owns the append-import transaction so the API route never holds a `trx`: * optionally creates the new columns, then inserts every row in CSV-sized @@ -206,7 +229,7 @@ export async function importAppendRows( addedRows: rows.length, }) const result = await db.transaction(async (trx) => { - let working = table + let working = await refreshUnderLock(trx, table) if (additions.length > 0) { // Take the row-order lock before creating columns so this path uses the // same rows_pos → user_table_definitions order as plain inserts. Creating @@ -214,7 +237,7 @@ export async function importAppendRows( // the order and deadlocking concurrent inserts on this table. The lock is // re-entrant, so the per-batch acquire below is a no-op. await acquireRowOrderLock(trx, table.id) - working = await addTableColumnsWithTx(trx, table, additions, ctx.requestId) + working = await addTableColumnsWithTx(trx, working, additions, ctx.requestId) } const inserted: TableRow[] = [] for (let i = 0; i < rows.length; i += CSV_MAX_BATCH_SIZE) { @@ -264,10 +287,10 @@ export async function importReplaceRows( addedRows: data.rows.length, }) const result = await db.transaction(async (trx) => { - let working = table + let working = await refreshUnderLock(trx, table) if (additions.length > 0) { await acquireRowOrderLock(trx, table.id) - working = await addTableColumnsWithTx(trx, table, additions, requestId) + working = await addTableColumnsWithTx(trx, working, additions, requestId) } return replaceTableRowsWithTx( trx, From 43e497d3d1e2a4d8b5fb93650c2f76b45c9ac656 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Sat, 25 Jul 2026 02:25:54 -0700 Subject: [PATCH 15/15] fix(tables): restore the workspace ownership check on copilot row deletes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Switching deleteRow/deleteRowsByIds to take a TableDefinition dropped the workspaceId argument that previously scoped the query, and the two branches I added loaded the table without the ownership comparison every other operation in the tool performs — letting a caller delete rows from a table in another workspace. Both now reject a foreign table as not found. --- apps/sim/lib/copilot/tools/server/table/user-table.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/sim/lib/copilot/tools/server/table/user-table.ts b/apps/sim/lib/copilot/tools/server/table/user-table.ts index bd1b95607f8..a88e1dee903 100644 --- a/apps/sim/lib/copilot/tools/server/table/user-table.ts +++ b/apps/sim/lib/copilot/tools/server/table/user-table.ts @@ -775,7 +775,10 @@ export const userTableServerTool: BaseServerTool const requestId = generateId().slice(0, 8) assertNotAborted() const deleteRowTable = await getTableById(args.tableId) - if (!deleteRowTable) { + // The old signature passed `workspaceId` into `deleteRow`, which scoped + // the query; taking a TableDefinition instead means the ownership check + // has to happen here, as every other operation in this tool does. + if (!deleteRowTable || deleteRowTable.workspaceId !== workspaceId) { return { success: false, message: `Table ${args.tableId} not found` } } await deleteRow(deleteRowTable, args.rowId, requestId) @@ -1101,7 +1104,7 @@ export const userTableServerTool: BaseServerTool const requestId = generateId().slice(0, 8) assertNotAborted() const batchDeleteTable = await getTableById(args.tableId) - if (!batchDeleteTable) { + if (!batchDeleteTable || batchDeleteTable.workspaceId !== workspaceId) { return { success: false, message: `Table ${args.tableId} not found` } } const result = await deleteRowsByIds(