diff --git a/apps/sim/app/api/folders/[id]/duplicate/route.test.ts b/apps/sim/app/api/folders/[id]/duplicate/route.test.ts new file mode 100644 index 00000000000..63f523fe8bc --- /dev/null +++ b/apps/sim/app/api/folders/[id]/duplicate/route.test.ts @@ -0,0 +1,164 @@ +/** + * Tests for the folder-duplicate route's target-parent ancestor walk. + * + * @vitest-environment node + */ +import { FolderLockedError } from '@sim/platform-authz/workflow' +import { describe, expect, it, vi } from 'vitest' +import { assertTargetParentFolderMutable } from '@/app/api/folders/[id]/duplicate/route' + +describe('assertTargetParentFolderMutable', () => { + const workspaceId = 'workspace-123' + const sourceFolderId = 'source-folder' + + /** + * `assertTargetParentFolderMutable` issues one `select().from().where().limit()` + * per ancestor hop, in walk order (target parent, then its parent, ...). + * `eq(folderTable.id, currentFolderId)` isn't inspectable without mocking + * drizzle-orm, so this mock returns each `chain` entry in call order instead. + */ + function buildTx(chain: Array | undefined>) { + let call = 0 + const limit = vi.fn().mockImplementation(() => { + const row = chain[call] + call += 1 + return row ? [row] : [] + }) + return { + select: vi.fn().mockReturnValue({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + for: vi.fn().mockReturnValue({ limit }), + limit, + }), + }), + }), + } as unknown as Parameters[0] + } + + it('allows a target parent whose full ancestor chain is active', async () => { + const tx = buildTx([ + { + id: 'parent', + parentId: 'grandparent', + locked: false, + workspaceId, + resourceType: 'workflow', + deletedAt: null, + }, + { + id: 'grandparent', + parentId: null, + locked: false, + workspaceId, + resourceType: 'workflow', + deletedAt: null, + }, + ]) + + await expect( + assertTargetParentFolderMutable(tx, 'parent', workspaceId, sourceFolderId) + ).resolves.toBeUndefined() + }) + + it('rejects when the immediate target parent is soft-deleted', async () => { + const tx = buildTx([ + { + id: 'parent', + parentId: null, + locked: false, + workspaceId, + resourceType: 'workflow', + deletedAt: new Date(), + }, + ]) + + await expect( + assertTargetParentFolderMutable(tx, 'parent', workspaceId, sourceFolderId) + ).rejects.toThrow('Target parent folder not found') + }) + + it('rejects when an ANCESTOR beyond the immediate parent is soft-deleted (regression: previously only checked the first hop)', async () => { + const tx = buildTx([ + { + id: 'parent', + parentId: 'grandparent', + locked: false, + workspaceId, + resourceType: 'workflow', + deletedAt: null, + }, + { + id: 'grandparent', + parentId: null, + locked: false, + workspaceId, + resourceType: 'workflow', + deletedAt: new Date(), + }, + ]) + + await expect( + assertTargetParentFolderMutable(tx, 'parent', workspaceId, sourceFolderId) + ).rejects.toThrow('Target parent folder not found') + }) + + it('rejects when the target parent belongs to a different workspace', async () => { + const tx = buildTx([ + { + id: 'parent', + parentId: null, + locked: false, + workspaceId: 'other-workspace', + resourceType: 'workflow', + deletedAt: null, + }, + ]) + + await expect( + assertTargetParentFolderMutable(tx, 'parent', workspaceId, sourceFolderId) + ).rejects.toThrow('Target parent folder not found') + }) + + it('rejects duplicating into the source folder itself', async () => { + const tx = buildTx([ + { + id: sourceFolderId, + parentId: null, + locked: false, + workspaceId, + resourceType: 'workflow', + deletedAt: null, + }, + ]) + + await expect( + assertTargetParentFolderMutable(tx, sourceFolderId, workspaceId, sourceFolderId) + ).rejects.toThrow('Cannot duplicate folder into itself or one of its descendants') + }) + + it('rejects when the target parent is locked', async () => { + const tx = buildTx([ + { + id: 'parent', + parentId: null, + locked: true, + workspaceId, + resourceType: 'workflow', + deletedAt: null, + }, + ]) + + await expect( + assertTargetParentFolderMutable(tx, 'parent', workspaceId, sourceFolderId) + ).rejects.toThrow(FolderLockedError) + }) + + it('is a no-op for a null parentId (duplicating to root)', async () => { + const tx = buildTx([]) + + await expect( + assertTargetParentFolderMutable(tx, null, workspaceId, sourceFolderId) + ).resolves.toBeUndefined() + }) +}) diff --git a/apps/sim/app/api/folders/[id]/duplicate/route.ts b/apps/sim/app/api/folders/[id]/duplicate/route.ts index 5e0df625558..f6cb4d7d442 100644 --- a/apps/sim/app/api/folders/[id]/duplicate/route.ts +++ b/apps/sim/app/api/folders/[id]/duplicate/route.ts @@ -1,6 +1,6 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' -import { workflow, workflowFolder } from '@sim/db/schema' +import { folder as folderTable, workflow } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { FolderLockedError } from '@sim/platform-authz/workflow' import { generateId } from '@sim/utils/id' @@ -17,7 +17,18 @@ import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' const logger = createLogger('FolderDuplicateAPI') -// POST /api/folders/[id]/duplicate - Duplicate a folder with all its child folders and workflows +/** + * POST /api/folders/[id]/duplicate — deep-copies a workflow folder (its child + * folder tree and every contained workflow). + * + * This endpoint is intentionally `workflow`-only: duplication means cloning + * workflow *definitions*, which has no generic equivalent for file/ + * knowledge_base/table folders yet, so — unlike the other `/api/folders` + * routes — it does not read `resourceType` off the row and dispatch. Passing + * a non-workflow folder id resolves to no source row and returns 404. When a + * second resource type gains a real duplicate semantic, lift this into a + * `performDuplicateFolder` dispatcher alongside the other orchestration fns. + */ export const POST = withRouteHandler( async (req: NextRequest, context: { params: Promise<{ id: string }> }) => { const { id: sourceFolderId } = await context.params @@ -33,14 +44,20 @@ export const POST = withRouteHandler( try { const parsed = await parseRequest(duplicateFolderContract, req, context) if (!parsed.success) return parsed.response - const { name, workspaceId, parentId, color, newId: clientNewId } = parsed.data.body + const { name, workspaceId, parentId, newId: clientNewId } = parsed.data.body logger.info(`[${requestId}] Duplicating folder ${sourceFolderId} for user ${session.user.id}`) const sourceFolder = await db .select() - .from(workflowFolder) - .where(and(eq(workflowFolder.id, sourceFolderId), isNull(workflowFolder.archivedAt))) + .from(folderTable) + .where( + and( + eq(folderTable.id, sourceFolderId), + eq(folderTable.resourceType, 'workflow'), + isNull(folderTable.deletedAt) + ) + ) .then((rows) => rows[0]) if (!sourceFolder) { @@ -69,17 +86,23 @@ export const POST = withRouteHandler( await assertTargetParentFolderMutable(tx, targetParentId, targetWorkspaceId, sourceFolderId) const folderParentCondition = targetParentId - ? eq(workflowFolder.parentId, targetParentId) - : isNull(workflowFolder.parentId) + ? eq(folderTable.parentId, targetParentId) + : isNull(folderTable.parentId) const workflowParentCondition = targetParentId ? eq(workflow.folderId, targetParentId) : isNull(workflow.folderId) const [[folderResult], [workflowResult]] = await Promise.all([ tx - .select({ minSortOrder: min(workflowFolder.sortOrder) }) - .from(workflowFolder) - .where(and(eq(workflowFolder.workspaceId, targetWorkspaceId), folderParentCondition)), + .select({ minSortOrder: min(folderTable.sortOrder) }) + .from(folderTable) + .where( + and( + eq(folderTable.workspaceId, targetWorkspaceId), + eq(folderTable.resourceType, 'workflow'), + folderParentCondition + ) + ), tx .select({ minSortOrder: min(workflow.sortOrder) }) .from(workflow) @@ -101,15 +124,14 @@ export const POST = withRouteHandler( name ) - await tx.insert(workflowFolder).values({ + await tx.insert(folderTable).values({ id: newFolderId, + resourceType: 'workflow', userId: session.user.id, workspaceId: targetWorkspaceId, name: deduplicatedName, - color: color || sourceFolder.color, parentId: targetParentId, sortOrder, - isExpanded: false, locked: false, createdAt: now, updatedAt: now, @@ -168,8 +190,8 @@ export const POST = withRouteHandler( const duplicatedFolder = await db .select() - .from(workflowFolder) - .where(eq(workflowFolder.id, newFolderId)) + .from(folderTable) + .where(and(eq(folderTable.id, newFolderId), eq(folderTable.resourceType, 'workflow'))) .then((rows) => rows[0]) return NextResponse.json({ folder: duplicatedFolder }, { status: 201 }) @@ -216,30 +238,59 @@ export const POST = withRouteHandler( } ) -async function assertTargetParentFolderMutable( +/** + * Verifies a prospective duplicate-target parent is usable: the same + * existence/workspace/resourceType/deleted invariant every other folder + * write path checks via `assertFolderParentValid` is applied inline to the + * immediate parent's row (the first loop iteration below), then the walk + * continues up the full ancestor chain applying the workflow-specific + * lock-cascade and self-descendant checks. `deletedAt` is re-checked on + * EVERY hop, not just the immediate parent — the `folder_parent_resource_type_match` + * trigger only fires when a `parent_id` edge is written, so it can't catch an + * ancestor further up the chain being soft-deleted independently afterwards + * (e.g. via a bulk admin import that writes `folder` rows directly). Only + * workspace/resourceType consistency is safe to check first-iteration-only, + * since the trigger enforces those on every write, including admin paths. + * Checking inline on the one row already being fetched for the walk avoids a + * redundant second SELECT of the same immediate-parent row that a separate + * `assertFolderParentValid` call would issue. + */ +export async function assertTargetParentFolderMutable( tx: DbOrTx, parentId: string | null, targetWorkspaceId: string, sourceFolderId: string ): Promise { - let currentFolderId = parentId + if (!parentId) return + + let currentFolderId: string | null = parentId const visited = new Set() + let isFirstIteration = true while (currentFolderId && !visited.has(currentFolderId)) { visited.add(currentFolderId) + // FOR UPDATE row-locks each ancestor for the rest of this transaction -- see the + // same comment on getFolderLockStatus in packages/platform-authz/src/resource-lock.ts. const [folder] = await tx .select({ - id: workflowFolder.id, - parentId: workflowFolder.parentId, - workspaceId: workflowFolder.workspaceId, - locked: workflowFolder.locked, - archivedAt: workflowFolder.archivedAt, + id: folderTable.id, + parentId: folderTable.parentId, + locked: folderTable.locked, + workspaceId: folderTable.workspaceId, + resourceType: folderTable.resourceType, + deletedAt: folderTable.deletedAt, }) - .from(workflowFolder) - .where(eq(workflowFolder.id, currentFolderId)) + .from(folderTable) + .where(eq(folderTable.id, currentFolderId)) + .for('update') .limit(1) - if (!folder || folder.workspaceId !== targetWorkspaceId || folder.archivedAt) { + if ( + !folder || + folder.deletedAt || + (isFirstIteration && + (folder.workspaceId !== targetWorkspaceId || folder.resourceType !== 'workflow')) + ) { throw new Error('Target parent folder not found') } if (folder.id === sourceFolderId) { @@ -250,6 +301,7 @@ async function assertTargetParentFolderMutable( } currentFolderId = folder.parentId + isFirstIteration = false } } @@ -260,16 +312,17 @@ async function deduplicateFolderName( requestedName: string ): Promise { const parentCondition = parentId - ? eq(workflowFolder.parentId, parentId) - : isNull(workflowFolder.parentId) + ? eq(folderTable.parentId, parentId) + : isNull(folderTable.parentId) const siblingRows = await tx - .select({ name: workflowFolder.name }) - .from(workflowFolder) + .select({ name: folderTable.name }) + .from(folderTable) .where( and( - eq(workflowFolder.workspaceId, workspaceId), + eq(folderTable.workspaceId, workspaceId), + eq(folderTable.resourceType, 'workflow'), parentCondition, - isNull(workflowFolder.archivedAt) + isNull(folderTable.deletedAt) ) ) const siblingNames = new Set(siblingRows.map((row) => row.name)) @@ -296,12 +349,13 @@ async function duplicateFolderStructure( ): Promise { const childFolders = await tx .select() - .from(workflowFolder) + .from(folderTable) .where( and( - eq(workflowFolder.parentId, sourceFolderId), - eq(workflowFolder.workspaceId, sourceWorkspaceId), - isNull(workflowFolder.archivedAt) + eq(folderTable.parentId, sourceFolderId), + eq(folderTable.workspaceId, sourceWorkspaceId), + eq(folderTable.resourceType, 'workflow'), + isNull(folderTable.deletedAt) ) ) @@ -309,15 +363,14 @@ async function duplicateFolderStructure( const newChildFolderId = generateId() folderMapping.set(childFolder.id, newChildFolderId) - await tx.insert(workflowFolder).values({ + await tx.insert(folderTable).values({ id: newChildFolderId, + resourceType: 'workflow', userId, workspaceId: targetWorkspaceId, name: childFolder.name, - color: childFolder.color, parentId: newParentFolderId, sortOrder: childFolder.sortOrder, - isExpanded: false, locked: false, createdAt: timestamp, updatedAt: timestamp, diff --git a/apps/sim/app/api/folders/[id]/restore/route.test.ts b/apps/sim/app/api/folders/[id]/restore/route.test.ts new file mode 100644 index 00000000000..22e71e461ef --- /dev/null +++ b/apps/sim/app/api/folders/[id]/restore/route.test.ts @@ -0,0 +1,119 @@ +/** + * Tests for the folder restore API route (/api/folders/[id]/restore) + * + * @vitest-environment node + */ +import { authMockFns, createMockRequest, permissionsMock, permissionsMockFns } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockPerformRestoreFolder, mockDbRef } = vi.hoisted(() => ({ + mockPerformRestoreFolder: vi.fn(), + mockDbRef: { current: null as any }, +})) + +vi.mock('@sim/logger', () => ({ + createLogger: vi.fn().mockReturnValue({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + trace: vi.fn(), + fatal: vi.fn(), + child: vi.fn(), + }), + runWithRequestContext: (_ctx: unknown, fn: () => T): T => fn(), + getRequestContext: () => undefined, +})) +vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock) +vi.mock('@sim/db', () => ({ + get db() { + return mockDbRef.current + }, +})) +vi.mock('@/lib/folders/orchestration', () => ({ + performRestoreFolder: mockPerformRestoreFolder, +})) + +import { POST } from '@/app/api/folders/[id]/restore/route' + +const mockGetUserEntityPermissions = permissionsMockFns.mockGetUserEntityPermissions + +function mockExistingFolder(row: unknown) { + mockDbRef.current = { + select: vi.fn().mockReturnValue({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue(row ? [row] : []), + }), + }), + }), + } +} + +describe('POST /api/folders/[id]/restore', () => { + beforeEach(() => { + vi.clearAllMocks() + authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-123' } }) + mockGetUserEntityPermissions.mockResolvedValue('admin') + mockExistingFolder({ resourceType: 'knowledge_base' }) + }) + + it('returns 404 when the folder row itself is not found', async () => { + mockExistingFolder(null) + + const req = createMockRequest('POST', { workspaceId: 'ws-1' }) + const response = await POST(req, { params: Promise.resolve({ id: 'folder-1' }) }) + + expect(response.status).toBe(404) + const data = await response.json() + expect(data.error).toBe('Folder not found') + expect(mockPerformRestoreFolder).not.toHaveBeenCalled() + }) + + it('returns 404 (not 400) when performRestoreFolder reports not_found', async () => { + // Regression test: the route previously hardcoded 400 for every orchestration + // failure, so a caller/monitor couldn't distinguish "not found" from any other + // validation failure. + mockPerformRestoreFolder.mockResolvedValueOnce({ + success: false, + error: 'Folder not found', + errorCode: 'not_found', + }) + + const req = createMockRequest('POST', { workspaceId: 'ws-1' }) + const response = await POST(req, { params: Promise.resolve({ id: 'folder-1' }) }) + + expect(response.status).toBe(404) + const data = await response.json() + expect(data.error).toBe('Folder not found') + }) + + it('returns 400 for a validation failure (e.g. folder is not archived)', async () => { + mockPerformRestoreFolder.mockResolvedValueOnce({ + success: false, + error: 'Folder is not archived', + errorCode: 'validation', + }) + + const req = createMockRequest('POST', { workspaceId: 'ws-1' }) + const response = await POST(req, { params: Promise.resolve({ id: 'folder-1' }) }) + + expect(response.status).toBe(400) + const data = await response.json() + expect(data.error).toBe('Folder is not archived') + }) + + it('returns 200 on success', async () => { + mockPerformRestoreFolder.mockResolvedValueOnce({ + success: true, + restoredItems: { folders: 1, knowledgeBases: 0 }, + }) + + const req = createMockRequest('POST', { workspaceId: 'ws-1' }) + const response = await POST(req, { params: Promise.resolve({ id: 'folder-1' }) }) + + expect(response.status).toBe(200) + const data = await response.json() + expect(data).toMatchObject({ success: true }) + }) +}) diff --git a/apps/sim/app/api/folders/[id]/restore/route.ts b/apps/sim/app/api/folders/[id]/restore/route.ts index 5ad28b90b28..a881bb46ec9 100644 --- a/apps/sim/app/api/folders/[id]/restore/route.ts +++ b/apps/sim/app/api/folders/[id]/restore/route.ts @@ -1,12 +1,17 @@ +import { db } from '@sim/db' +import { folder } from '@sim/db/schema' import { createLogger } from '@sim/logger' +import { ResourceLockedError } from '@sim/platform-authz/resource-lock' import { getErrorMessage } from '@sim/utils/errors' +import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { restoreFolderContract } from '@/lib/api/contracts' import { parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { performRestoreFolder } from '@/lib/folders/orchestration' import { captureServerEvent } from '@/lib/posthog/server' -import { performRestoreFolder } from '@/lib/workflows/orchestration/folder-lifecycle' +import { statusForOrchestrationError } from '@/lib/workflows/orchestration/types' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' const logger = createLogger('RestoreFolderAPI') @@ -30,14 +35,28 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Route return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) } + const [existingFolder] = await db + .select({ resourceType: folder.resourceType }) + .from(folder) + .where(and(eq(folder.id, folderId), eq(folder.workspaceId, workspaceId))) + .limit(1) + + if (!existingFolder) { + return NextResponse.json({ error: 'Folder not found' }, { status: 404 }) + } + const result = await performRestoreFolder({ + resourceType: existingFolder.resourceType, folderId, workspaceId, userId: session.user.id, }) if (!result.success) { - return NextResponse.json({ error: result.error }, { status: 400 }) + return NextResponse.json( + { error: result.error }, + { status: statusForOrchestrationError(result.errorCode) } + ) } logger.info(`Restored folder ${folderId}`, { restoredItems: result.restoredItems }) @@ -51,6 +70,10 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Route return NextResponse.json({ success: true, restoredItems: result.restoredItems }) } catch (error) { + if (error instanceof ResourceLockedError) { + return NextResponse.json({ error: error.message }, { status: error.status }) + } + logger.error('Error restoring folder', error) return NextResponse.json( { error: getErrorMessage(error, 'Internal server error') }, diff --git a/apps/sim/app/api/folders/[id]/route.test.ts b/apps/sim/app/api/folders/[id]/route.test.ts index 95cb3d53b05..f0f08f32c1e 100644 --- a/apps/sim/app/api/folders/[id]/route.test.ts +++ b/apps/sim/app/api/folders/[id]/route.test.ts @@ -3,6 +3,7 @@ * * @vitest-environment node */ +import { ResourceLockedError } from '@sim/platform-authz/resource-lock' import { auditMock, authMockFns, @@ -10,14 +11,12 @@ import { type MockUser, permissionsMock, permissionsMockFns, - workflowsOrchestrationMock, + resourceLockMockFns, workflowsOrchestrationMockFns, - workflowsUtilsMock, - workflowsUtilsMockFns, } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockLogger, mockDbRef } = vi.hoisted(() => { +const { mockLogger, mockDbRef, mockCheckFolderCircularReference } = vi.hoisted(() => { const logger = { info: vi.fn(), warn: vi.fn(), @@ -30,6 +29,10 @@ const { mockLogger, mockDbRef } = vi.hoisted(() => { return { mockLogger: logger, mockDbRef: { current: null as any }, + // Stands in for the real cycle-check the mocked performUpdateFolder below + // hand-simulates -- not tied to a real module export, just an internal + // test-double control lever for exercising the route's error handling. + mockCheckFolderCircularReference: vi.fn(), } }) @@ -50,8 +53,10 @@ vi.mock('@sim/db', () => ({ return mockDbRef.current }, })) -vi.mock('@/lib/workflows/orchestration', () => workflowsOrchestrationMock) -vi.mock('@/lib/workflows/utils', () => workflowsUtilsMock) +vi.mock('@/lib/folders/orchestration', () => ({ + performDeleteFolder: workflowsOrchestrationMockFns.mockPerformDeleteFolder, + performUpdateFolder: workflowsOrchestrationMockFns.mockPerformUpdateFolder, +})) import { DELETE, PUT } from '@/app/api/folders/[id]/route' @@ -70,11 +75,11 @@ const TEST_USER: MockUser = { const mockFolder = { id: 'folder-1', + resourceType: 'workflow', name: 'Test Folder', userId: TEST_USER.id, workspaceId: 'workspace-123', parentId: null, - color: '#6B7280', sortOrder: 1, createdAt: new Date('2024-01-01T00:00:00Z'), updatedAt: new Date('2024-01-01T00:00:00Z'), @@ -161,10 +166,7 @@ describe('Individual Folder API Route', () => { } if ( params.parentId && - (await workflowsUtilsMockFns.mockCheckForCircularReference( - params.folderId, - params.parentId - )) + (await mockCheckFolderCircularReference(params.folderId, params.parentId)) ) { return { success: false, @@ -178,15 +180,13 @@ describe('Individual Folder API Route', () => { ...mockFolder, id: params.folderId, name: params.name !== undefined ? params.name.trim() : 'Updated Folder', - color: params.color ?? mockFolder.color, parentId: params.parentId ?? mockFolder.parentId, - isExpanded: params.isExpanded, sortOrder: params.sortOrder ?? mockFolder.sortOrder, updatedAt: new Date(), }, } }) - workflowsUtilsMockFns.mockCheckForCircularReference.mockResolvedValue(false) + mockCheckFolderCircularReference.mockResolvedValue(false) }) describe('PUT /api/folders/[id]', () => { @@ -195,7 +195,6 @@ describe('Individual Folder API Route', () => { const req = createMockRequest('PUT', { name: 'Updated Folder Name', - color: '#FF0000', }) const params = Promise.resolve({ id: 'folder-1' }) @@ -210,6 +209,85 @@ describe('Individual Folder API Route', () => { }) }) + it('returns 404 for an archived (soft-deleted) folder rather than updating it', async () => { + // Regression test: the existingFolder lookup previously had no deletedAt + // filter, so a soft-deleted folder could still be renamed/reparented/ + // relocked via this route even though it no longer appears anywhere in + // the UI. Simulates the lookup finding nothing (as it now would for an + // archived row, since the route filters isNull(deletedAt)). + mockAuthenticatedUser() + mockDbRef.current = { + select: vi.fn().mockReturnValue({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + then: vi.fn().mockImplementation((callback) => Promise.resolve(callback([]))), + }), + }), + }), + update: vi.fn(), + delete: vi.fn(), + } + + const req = createMockRequest('PUT', { name: 'Should not apply' }) + const params = Promise.resolve({ id: 'folder-1' }) + + const response = await PUT(req, { params }) + + expect(response.status).toBe(404) + const data = await response.json() + expect(data.error).toBe('Folder not found') + expect(mockPerformUpdateFolder).not.toHaveBeenCalled() + }) + + it('allows unlocking a directly-locked folder combined with a move in the same request', async () => { + // Regression test: hasNonLockUpdate is true whenever parentId also changes, so + // a combined "unlock + move" request previously still ran + // policy.assertMutable(id) against the folder's current (still-locked) state + // and was incorrectly rejected, even though the request unlocks it as part of + // this same atomic write. The fixed behavior still runs the check (so an + // inherited lock is caught below), but treats a DIRECT lock as satisfied + // since this request clears it. + mockAuthenticatedUser() + resourceLockMockFns.mockAssertFolderMutable.mockReset() + resourceLockMockFns.mockAssertFolderMutable.mockResolvedValue(undefined) + resourceLockMockFns.mockAssertFolderMutable.mockRejectedValueOnce( + new ResourceLockedError('workflow', false, 'Folder is locked') + ) + + const req = createMockRequest('PUT', { locked: false, parentId: 'folder-2' }) + const params = Promise.resolve({ id: 'folder-1' }) + + const response = await PUT(req, { params }) + + expect(response.status).toBe(200) + expect(resourceLockMockFns.mockAssertFolderMutable).toHaveBeenCalledTimes(2) + expect(resourceLockMockFns.mockAssertFolderMutable).toHaveBeenCalledWith( + 'folder-1', + 'workflow' + ) + expect(resourceLockMockFns.mockAssertFolderMutable).toHaveBeenCalledWith( + 'folder-2', + 'workflow' + ) + }) + + it('still rejects unlocking a folder combined with a move when the lock is inherited from an ancestor', async () => { + // Clearing the folder's own `locked` flag doesn't affect a lock inherited from + // an ancestor -- that must still block the combined request. + mockAuthenticatedUser() + resourceLockMockFns.mockAssertFolderMutable.mockReset() + resourceLockMockFns.mockAssertFolderMutable.mockRejectedValueOnce( + new ResourceLockedError('workflow', true, 'Folder is locked by an ancestor folder') + ) + + const req = createMockRequest('PUT', { locked: false, parentId: 'folder-2' }) + const params = Promise.resolve({ id: 'folder-1' }) + + const response = await PUT(req, { params }) + + expect(response.status).toBe(423) + }) + it('should update parent folder successfully', async () => { mockAuthenticatedUser() @@ -386,13 +464,14 @@ describe('Individual Folder API Route', () => { mockDbRef.current = createFolderDbMock({ folderLookupResult: { id: 'folder-3', + resourceType: 'workflow', parentId: null, name: 'Folder 3', workspaceId: 'workspace-123', }, }) - workflowsUtilsMockFns.mockCheckForCircularReference.mockResolvedValue(true) + mockCheckFolderCircularReference.mockResolvedValue(true) const req = createMockRequest('PUT', { name: 'Updated Folder 3', @@ -406,10 +485,7 @@ describe('Individual Folder API Route', () => { const data = await response.json() expect(data).toHaveProperty('error', 'Cannot create circular folder reference') - expect(workflowsUtilsMockFns.mockCheckForCircularReference).toHaveBeenCalledWith( - 'folder-3', - 'folder-1' - ) + expect(mockCheckFolderCircularReference).toHaveBeenCalledWith('folder-3', 'folder-1') }) }) @@ -432,6 +508,7 @@ describe('Individual Folder API Route', () => { expect(data).toHaveProperty('success', true) expect(data).toHaveProperty('deletedItems') expect(mockPerformDeleteFolder).toHaveBeenCalledWith({ + resourceType: 'workflow', folderId: 'folder-1', workspaceId: 'workspace-123', userId: TEST_USER.id, diff --git a/apps/sim/app/api/folders/[id]/route.ts b/apps/sim/app/api/folders/[id]/route.ts index 48483258eb1..980dab74d88 100644 --- a/apps/sim/app/api/folders/[id]/route.ts +++ b/apps/sim/app/api/folders/[id]/route.ts @@ -1,20 +1,24 @@ import { db } from '@sim/db' -import { workflowFolder } from '@sim/db/schema' +import { folder } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { assertFolderMutable, FolderLockedError } from '@sim/platform-authz/workflow' -import { eq } from 'drizzle-orm' +import { + assertFolderMutableUnlessUnlocking, + ResourceLockedError, +} from '@sim/platform-authz/resource-lock' +import { and, eq, isNull } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { updateFolderContract } from '@/lib/api/contracts' import { parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { performDeleteFolder, performUpdateFolder } from '@/lib/folders/orchestration' +import { FOLDER_RESOURCE_POLICIES } from '@/lib/folders/policy' import { captureServerEvent } from '@/lib/posthog/server' -import { performDeleteFolder, performUpdateFolder } from '@/lib/workflows/orchestration' +import { statusForOrchestrationError } from '@/lib/workflows/orchestration/types' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' const logger = createLogger('FoldersIDAPI') -// PUT - Update a folder export const PUT = withRouteHandler( async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { try { @@ -38,20 +42,18 @@ export const PUT = withRouteHandler( if (!parsed.success) return parsed.response const { id } = parsed.data.params - const { name, color, isExpanded, locked, parentId, sortOrder } = parsed.data.body + const { name, locked, parentId, sortOrder } = parsed.data.body - // Verify the folder exists const existingFolder = await db .select() - .from(workflowFolder) - .where(eq(workflowFolder.id, id)) + .from(folder) + .where(and(eq(folder.id, id), isNull(folder.deletedAt))) .then((rows) => rows[0]) if (!existingFolder) { return NextResponse.json({ error: 'Folder not found' }, { status: 404 }) } - // Check if user has write permissions for the workspace const workspacePermission = await getUserEntityPermissions( session.user.id, 'workspace', @@ -65,6 +67,14 @@ export const PUT = withRouteHandler( ) } + const policy = FOLDER_RESOURCE_POLICIES[existingFolder.resourceType] + + if (locked !== undefined && !policy.supportsLocking) { + return NextResponse.json( + { error: `Folders of type "${existingFolder.resourceType}" do not support locking` }, + { status: 400 } + ) + } if (locked !== undefined && workspacePermission !== 'admin') { return NextResponse.json( { error: 'Admin access required to lock folders' }, @@ -72,37 +82,42 @@ export const PUT = withRouteHandler( ) } + // An admin combining `locked: false` with other field changes (e.g. a move) in + // one request is unlocking the folder as part of this same atomic write -- the + // mutable-check must not treat that request's own current (about-to-be-cleared) + // lock as blocking. It must still enforce a lock INHERITED from an ancestor, + // since clearing this folder's own `locked` flag doesn't affect that. const hasNonLockUpdate = Object.keys(parsed.data.body).some((key) => key !== 'locked') if (hasNonLockUpdate) { - await assertFolderMutable(id) + await assertFolderMutableUnlessUnlocking(id, existingFolder.resourceType, locked === false) } if (parentId !== undefined) { - await assertFolderMutable(parentId) + await policy.assertMutable(parentId) } const result = await performUpdateFolder({ + resourceType: existingFolder.resourceType, folderId: id, workspaceId: existingFolder.workspaceId, userId: session.user.id, name, - color, - isExpanded, locked, parentId, sortOrder, }) if (!result.success || !result.folder) { - const status = - result.errorCode === 'not_found' ? 404 : result.errorCode === 'validation' ? 400 : 500 - return NextResponse.json({ error: result.error }, { status }) + return NextResponse.json( + { error: result.error }, + { status: statusForOrchestrationError(result.errorCode) } + ) } logger.info('Updated folder:', { id, updates: parsed.data.body }) return NextResponse.json({ folder: result.folder }) } catch (error) { - if (error instanceof FolderLockedError) { + if (error instanceof ResourceLockedError) { return NextResponse.json({ error: error.message }, { status: error.status }) } @@ -112,7 +127,6 @@ export const PUT = withRouteHandler( } ) -// DELETE - Delete a folder and all its contents export const DELETE = withRouteHandler( async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { try { @@ -123,11 +137,10 @@ export const DELETE = withRouteHandler( const { id } = await params - // Verify the folder exists const existingFolder = await db .select() - .from(workflowFolder) - .where(eq(workflowFolder.id, id)) + .from(folder) + .where(and(eq(folder.id, id), isNull(folder.deletedAt))) .then((rows) => rows[0]) if (!existingFolder) { @@ -147,9 +160,10 @@ export const DELETE = withRouteHandler( ) } - await assertFolderMutable(id) + await FOLDER_RESOURCE_POLICIES[existingFolder.resourceType].assertMutable(id) const result = await performDeleteFolder({ + resourceType: existingFolder.resourceType, folderId: id, workspaceId: existingFolder.workspaceId, userId: session.user.id, @@ -157,9 +171,10 @@ export const DELETE = withRouteHandler( }) if (!result.success) { - const status = - result.errorCode === 'not_found' ? 404 : result.errorCode === 'validation' ? 400 : 500 - return NextResponse.json({ error: result.error }, { status }) + return NextResponse.json( + { error: result.error }, + { status: statusForOrchestrationError(result.errorCode) } + ) } captureServerEvent( @@ -174,7 +189,7 @@ export const DELETE = withRouteHandler( deletedItems: result.deletedItems, }) } catch (error) { - if (error instanceof FolderLockedError) { + if (error instanceof ResourceLockedError) { return NextResponse.json({ error: error.message }, { status: error.status }) } diff --git a/apps/sim/app/api/folders/reorder/route.test.ts b/apps/sim/app/api/folders/reorder/route.test.ts index 61b56e6fa50..dda02662a46 100644 --- a/apps/sim/app/api/folders/reorder/route.test.ts +++ b/apps/sim/app/api/folders/reorder/route.test.ts @@ -3,7 +3,14 @@ * * @vitest-environment node */ -import { authMockFns, createMockRequest, permissionsMock, permissionsMockFns } from '@sim/testing' + +import { + authMockFns, + createMockRequest, + permissionsMock, + permissionsMockFns, + resourceLockMockFns, +} from '@sim/testing' import { drizzleOrmMock } from '@sim/testing/mocks' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -37,10 +44,38 @@ const mockDb = db as any describe('PUT /api/folders/reorder', () => { const mockFrom = vi.fn() const mockWhere = vi.fn() + const mockLimit = vi.fn() const mockTxUpdate = vi.fn() + const mockTxSelect = vi.fn() + + /** + * `performReorderFolders` issues several distinct `tx.select(...).from(...).where(...)` + * calls inside the transaction (the active-parents recheck, the ancestor-closure + * walk, and the final `ORDER BY id FOR UPDATE` lock read) -- this queue returns + * each call's result in order and makes the returned builder both directly + * awaitable and chainable via `.orderBy()`/`.for()` (both no-ops that return the + * same thenable), matching how drizzle's query builder supports either usage. + */ + function queueTxSelectResults(...results: unknown[][]) { + let call = 0 + mockTxSelect.mockImplementation(() => ({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockImplementation(() => { + const result = results[call] ?? [] + call += 1 + const thenable: any = Promise.resolve(result) + thenable.orderBy = vi.fn().mockReturnValue(thenable) + thenable.for = vi.fn().mockReturnValue(thenable) + return thenable + }), + }), + })) + } beforeEach(() => { vi.clearAllMocks() + resourceLockMockFns.mockAssertFolderMutable.mockReset() + resourceLockMockFns.mockAssertFolderMutable.mockResolvedValue(undefined) authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-123' } }) mockGetUserEntityPermissions.mockResolvedValue('admin') @@ -49,17 +84,27 @@ describe('PUT /api/folders/reorder', () => { mockFrom.mockReturnValue({ where: mockWhere }) mockTxUpdate.mockReturnValue({ - set: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(undefined) }), + set: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + returning: vi.fn().mockResolvedValue([{ id: 'folder-1' }]), + }), + }), }) + // Default: closure walk finds no parent (chain ends immediately), lock read + // finds the folder unlocked. + queueTxSelectResults([{ id: 'folder-1', parentId: null }], [{ id: 'folder-1', locked: false }]) mockDb.transaction.mockImplementation(async (cb: (tx: unknown) => Promise) => - cb({ update: mockTxUpdate }) + cb({ update: mockTxUpdate, select: mockTxSelect }) ) }) it('reorders folders when updates are valid', async () => { mockWhere - .mockReturnValueOnce([{ id: 'folder-1', workspaceId: 'workspace-123' }]) + .mockReturnValueOnce([ + { id: 'folder-1', workspaceId: 'workspace-123', resourceType: 'workflow' }, + ]) .mockReturnValueOnce([{ id: 'folder-1', parentId: null }]) + .mockReturnValueOnce([{ id: 'folder-1', workspaceId: 'workspace-123' }]) const req = createMockRequest('PUT', { workspaceId: 'workspace-123', @@ -68,15 +113,60 @@ describe('PUT /api/folders/reorder', () => { const response = await PUT(req) - expect(response.status).toBe(200) const data = await response.json() + expect(response.status).toBe(200) expect(data).toMatchObject({ success: true, updated: 1 }) }) - it('rejects a parentId that belongs to another workspace', async () => { + it('scopes the cycle-detection graph to the batch resourceType and excludes soft-deleted folders', async () => { + // Regression test: the workspaceFolders query used to build the cycle-check + // graph previously fetched every folder in the workspace regardless of + // resourceType or deletedAt -- unrelated trees and deleted nodes could + // pollute the ancestor walk and produce a false "circular reference" error. mockWhere + .mockReturnValueOnce([ + { id: 'folder-1', workspaceId: 'workspace-123', resourceType: 'workflow' }, + ]) + .mockReturnValueOnce([{ id: 'folder-1', parentId: null }]) + .mockReturnValueOnce([{ id: 'folder-1', workspaceId: 'workspace-123' }]) + + const req = createMockRequest('PUT', { + workspaceId: 'workspace-123', + updates: [{ id: 'folder-1', sortOrder: 2, parentId: null }], + }) + + await PUT(req) + + const workspaceFoldersCondition = mockWhere.mock.calls[1][0] + expect(workspaceFoldersCondition).toMatchObject({ + type: 'and', + conditions: expect.arrayContaining([ + { type: 'eq', left: expect.anything(), right: 'workspace-123' }, + { type: 'eq', left: expect.anything(), right: 'workflow' }, + { type: 'isNull', column: expect.anything() }, + ]), + }) + }) + + it('rejects a parentId that belongs to another workspace, failing the whole batch', async () => { + // Parent-id validity is checked once, inside `performReorderFolders` + // (via `assertFolderParentValid`) — the route no longer re-implements + // this check, so the mock sequence covers: (1) route's own-id lookup, + // (2) route's workspace-folders lookup for the circular-reference walk, + // (3) orchestration's own-id lookup, (4) orchestration's parentId lookup. + // A single invalid parentId fails the ENTIRE batch (matching this + // endpoint's pre-generalization all-or-nothing behavior) rather than + // silently skipping just that one entry. + mockWhere + .mockReturnValueOnce([ + { id: 'folder-1', workspaceId: 'workspace-123', resourceType: 'workflow' }, + ]) + .mockReturnValueOnce([]) .mockReturnValueOnce([{ id: 'folder-1', workspaceId: 'workspace-123' }]) - .mockReturnValueOnce([{ id: 'foreign', workspaceId: 'workspace-OTHER', archivedAt: null }]) + .mockReturnValueOnce({ limit: mockLimit }) + mockLimit.mockReturnValueOnce([ + { workspaceId: 'workspace-OTHER', resourceType: 'workflow', deletedAt: null }, + ]) const req = createMockRequest('PUT', { workspaceId: 'workspace-123', @@ -91,6 +181,340 @@ describe('PUT /api/folders/reorder', () => { expect(mockDb.transaction).not.toHaveBeenCalled() }) + it('rejects a batch spanning more than one resourceType', async () => { + mockWhere.mockReturnValueOnce([ + { id: 'folder-1', workspaceId: 'workspace-123', resourceType: 'workflow' }, + { id: 'folder-2', workspaceId: 'workspace-123', resourceType: 'file' }, + ]) + + const req = createMockRequest('PUT', { + workspaceId: 'workspace-123', + updates: [ + { id: 'folder-1', sortOrder: 0, parentId: null }, + { id: 'folder-2', sortOrder: 0, parentId: null }, + ], + }) + + const response = await PUT(req) + + expect(response.status).toBe(400) + const data = await response.json() + expect(data.error).toBe('All folders in a reorder batch must share the same resourceType') + expect(mockDb.transaction).not.toHaveBeenCalled() + }) + + it('rejects the whole batch when one folder id in a multi-item request is not found', async () => { + // Regression test: previously a request mixing a valid and an invalid/missing + // id would silently reorder only the valid one and report success with a + // smaller `updated` count, giving no indication the other entry was skipped. + mockWhere.mockReturnValueOnce([ + { id: 'folder-1', workspaceId: 'workspace-123', resourceType: 'workflow' }, + ]) + + const req = createMockRequest('PUT', { + workspaceId: 'workspace-123', + updates: [ + { id: 'folder-1', sortOrder: 0, parentId: null }, + { id: 'folder-missing', sortOrder: 1, parentId: null }, + ], + }) + + const response = await PUT(req) + + expect(response.status).toBe(400) + const data = await response.json() + expect(data.error).toBe('One or more folders were not found') + expect(mockDb.transaction).not.toHaveBeenCalled() + }) + + it('rejects a soft-deleted folder id rather than silently reordering it', async () => { + // Regression test: the route's own existing-folder lookup previously had no + // deletedAt filter, so a soft-deleted folder id would pass validation and + // have its sortOrder/parentId mutated by a stale or direct API request. + mockWhere.mockReturnValueOnce([]) + + const req = createMockRequest('PUT', { + workspaceId: 'workspace-123', + updates: [{ id: 'folder-deleted', sortOrder: 0, parentId: null }], + }) + + const response = await PUT(req) + + expect(response.status).toBe(400) + const data = await response.json() + expect(data.error).toBe('One or more folders were not found') + expect(mockDb.transaction).not.toHaveBeenCalled() + }) + + it('rejects an empty updates array', async () => { + // Regression test: an empty `updates` array previously passed contract + // validation and crashed the route (validUpdates[0] was undefined) instead + // of failing cleanly. + const req = createMockRequest('PUT', { workspaceId: 'workspace-123', updates: [] }) + + const response = await PUT(req) + + expect(response.status).toBe(400) + expect(mockDb.transaction).not.toHaveBeenCalled() + }) + + it('rolls back the whole batch when a folder is concurrently deleted before the write', async () => { + // Regression test: the transactional write previously only filtered by id, + // so a folder soft-deleted between validation and the transaction could + // still have its sortOrder/parentId mutated. The write-time recheck should + // find no matching row and roll back rather than silently applying it. + mockWhere + .mockReturnValueOnce([ + { id: 'folder-1', workspaceId: 'workspace-123', resourceType: 'workflow' }, + ]) + .mockReturnValueOnce([{ id: 'folder-1', parentId: null }]) + .mockReturnValueOnce([{ id: 'folder-1', workspaceId: 'workspace-123' }]) + + mockTxUpdate.mockReturnValue({ + set: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + returning: vi.fn().mockResolvedValue([]), // row no longer matches (deletedAt set) + }), + }), + }) + + const req = createMockRequest('PUT', { + workspaceId: 'workspace-123', + updates: [{ id: 'folder-1', sortOrder: 2, parentId: null }], + }) + + const response = await PUT(req) + + expect(response.status).toBe(404) + const data = await response.json() + expect(data.error).toBe('One or more folders were not found') + }) + + it('returns a 500 when the transaction fails for an unexpected reason', async () => { + // Regression test: performReorderFolders previously mapped every non-lock + // transaction failure -- including genuine unexpected DB errors, not just + // client-caused validation issues -- to a 400 in the route. An internal + // failure should surface as a 500 with a generic message, not leak the raw + // error or masquerade as a client error. + mockWhere + .mockReturnValueOnce([ + { id: 'folder-1', workspaceId: 'workspace-123', resourceType: 'workflow' }, + ]) + .mockReturnValueOnce([{ id: 'folder-1', parentId: null }]) + .mockReturnValueOnce([{ id: 'folder-1', workspaceId: 'workspace-123' }]) + + mockDb.transaction.mockImplementation(async () => { + throw new Error('connection terminated unexpectedly') + }) + + const req = createMockRequest('PUT', { + workspaceId: 'workspace-123', + updates: [{ id: 'folder-1', sortOrder: 2, parentId: null }], + }) + + const response = await PUT(req) + + expect(response.status).toBe(500) + const data = await response.json() + expect(data.error).toBe('Failed to reorder folders') + }) + + it('returns a 423 when a folder is concurrently locked before the write', async () => { + // Regression test: the route checks lock state before calling + // performReorderFolders (both of the route's own checks -- for update.id and + // update.parentId, since parentId is `null` not `undefined` -- succeed below, + // simulating the folder being unlocked at that moment), but that's a separate + // round-trip -- an admin could lock the folder in the window between that + // check and the transaction. The in-transaction ORDER BY id FOR UPDATE lock + // read is the recheck this test targets. + mockWhere + .mockReturnValueOnce([ + { id: 'folder-1', workspaceId: 'workspace-123', resourceType: 'workflow' }, + ]) + .mockReturnValueOnce([{ id: 'folder-1', parentId: null }]) + .mockReturnValueOnce([{ id: 'folder-1', workspaceId: 'workspace-123' }]) + + queueTxSelectResults([{ id: 'folder-1', parentId: null }], [{ id: 'folder-1', locked: true }]) + + const req = createMockRequest('PUT', { + workspaceId: 'workspace-123', + updates: [{ id: 'folder-1', sortOrder: 2, parentId: null }], + }) + + const response = await PUT(req) + + expect(response.status).toBe(423) + expect(mockDb.transaction).toHaveBeenCalledTimes(1) + expect(mockTxUpdate).not.toHaveBeenCalled() + }) + + it('rejects the write when an ancestor beyond the starting folder is locked', async () => { + // Regression test: the lock check must walk to ancestors not directly named in + // the batch (folder-1's own parent) and include them in the single ordered + // lock read -- not just the ids explicitly present in the batch. This also + // guards the closure-based fix for the deadlock finding: locking each + // starting id's ancestor chain separately can still deadlock a concurrent + // batch, so the whole closure must be computed first and locked in one + // ORDER BY id FOR UPDATE statement. + mockWhere + .mockReturnValueOnce([ + { id: 'folder-1', workspaceId: 'workspace-123', resourceType: 'workflow' }, + ]) + .mockReturnValueOnce([{ id: 'folder-1', parentId: 'ancestor-1' }]) + .mockReturnValueOnce([{ id: 'folder-1', workspaceId: 'workspace-123' }]) + + queueTxSelectResults( + [{ id: 'folder-1', parentId: 'ancestor-1' }], // closure walk, level 1 + [{ id: 'ancestor-1', parentId: null }], // closure walk, level 2 (chain ends) + [ + { id: 'ancestor-1', locked: true }, + { id: 'folder-1', locked: false }, + ] // single ordered lock read over the whole closure + ) + + const req = createMockRequest('PUT', { + workspaceId: 'workspace-123', + updates: [{ id: 'folder-1', sortOrder: 2, parentId: null }], + }) + + const response = await PUT(req) + + expect(response.status).toBe(423) + expect(mockTxUpdate).not.toHaveBeenCalled() + }) + + it('rejects the write when the target parent is concurrently soft-deleted', async () => { + // Regression test: assertFolderParentValid only validates the target parent at + // request-validation time, using the default (non-tx) db client -- a parent + // soft-deleted after that read but before (or during) this transaction must + // still be caught. The closure-lock query is the only point where this is + // race-free: once FOR UPDATE is held on the parent's row, its deletedAt state + // can't change until this transaction resolves, so that's where the final + // active check must happen -- not an earlier, unlocked pre-check. + mockWhere + .mockReturnValueOnce([ + { id: 'folder-1', workspaceId: 'workspace-123', resourceType: 'workflow' }, + ]) + .mockReturnValueOnce([]) + .mockReturnValueOnce([{ id: 'folder-1', workspaceId: 'workspace-123' }]) + .mockReturnValueOnce({ limit: mockLimit }) + mockLimit.mockReturnValueOnce([ + { workspaceId: 'workspace-123', resourceType: 'workflow', deletedAt: null }, + ]) + + queueTxSelectResults( + [ + { id: 'folder-1', parentId: null }, + { id: 'target-1', parentId: null }, + ], // closure walk, level 1 + [ + { id: 'folder-1', locked: false, deletedAt: null }, + { id: 'target-1', locked: false, deletedAt: new Date() }, // deleted after validation + ] + ) + + const req = createMockRequest('PUT', { + workspaceId: 'workspace-123', + updates: [{ id: 'folder-1', sortOrder: 0, parentId: 'target-1' }], + }) + + const response = await PUT(req) + + expect(response.status).toBe(404) + const data = await response.json() + expect(data.error).toBe('Parent folder not found') + expect(mockTxUpdate).not.toHaveBeenCalled() + }) + + it('rejects the write when a locked row points outside the discovered closure', async () => { + // Regression test: the ancestor-closure walk discovers members with PLAIN reads + // before the FOR UPDATE lock is acquired. If a concurrent request reparents one + // of those discovered members (target-1 here) to point at a folder outside the + // closure in that gap, the locked read reveals a parentId the cycle walk has no + // data for -- silently treating "not in closure" as root could let a real cycle + // through undetected. The fix fails the whole batch safely instead. + mockWhere + .mockReturnValueOnce([ + { id: 'folder-1', workspaceId: 'workspace-123', resourceType: 'workflow' }, + ]) + .mockReturnValueOnce([{ id: 'folder-1', parentId: null }]) + .mockReturnValueOnce([{ id: 'folder-1', workspaceId: 'workspace-123' }]) + .mockReturnValueOnce({ limit: mockLimit }) + mockLimit.mockReturnValueOnce([ + { workspaceId: 'workspace-123', resourceType: 'workflow', deletedAt: null }, + ]) + + queueTxSelectResults( + // closure walk, level 1: both starting ids resolve as roots (no further + // ancestors) at discovery time -- the walk correctly stops here. + [ + { id: 'folder-1', parentId: null }, + { id: 'target-1', parentId: null }, + ], + [ + { id: 'folder-1', locked: false, deletedAt: null }, + // Concurrently reparented to a folder never discovered by the walk above. + { id: 'target-1', locked: false, deletedAt: null, parentId: 'outside-closure' }, + ] + ) + + const req = createMockRequest('PUT', { + workspaceId: 'workspace-123', + updates: [{ id: 'folder-1', sortOrder: 0, parentId: 'target-1' }], + }) + + const response = await PUT(req) + + expect(response.status).toBe(409) + const data = await response.json() + expect(data.error).toBe('Folder tree changed concurrently, please retry') + expect(mockTxUpdate).not.toHaveBeenCalled() + }) + + it('rejects the write when a concurrent request created a cycle between the pre-check and the transaction', async () => { + // Regression test: the route's own cycle check reads an unlocked snapshot of + // the folder tree before this transaction opens. Two concurrent requests can + // each move one end of what becomes an A<->B cycle, both passing their own + // (mutually stale) check -- simulated here by having the pre-check snapshot + // show B's parentId as still null (stale), while the locked, tx-consistent + // read inside the transaction reflects a concurrent request that already + // committed B.parentId = A. + mockWhere + .mockReturnValueOnce([{ id: 'A', workspaceId: 'workspace-123', resourceType: 'workflow' }]) + .mockReturnValueOnce([ + { id: 'A', parentId: null }, + { id: 'B', parentId: null }, + ]) + .mockReturnValueOnce([{ id: 'A', workspaceId: 'workspace-123' }]) + .mockReturnValueOnce({ limit: mockLimit }) + mockLimit.mockReturnValueOnce([ + { workspaceId: 'workspace-123', resourceType: 'workflow', deletedAt: null }, + ]) + + queueTxSelectResults( + [ + { id: 'A', parentId: null }, + { id: 'B', parentId: null }, + ], // closure walk, level 1 + [ + { id: 'A', parentId: null, locked: false, deletedAt: null }, + { id: 'B', parentId: 'A', locked: false, deletedAt: null }, // committed by a concurrent request + ] + ) + + const req = createMockRequest('PUT', { + workspaceId: 'workspace-123', + updates: [{ id: 'A', sortOrder: 0, parentId: 'B' }], + }) + + const response = await PUT(req) + + expect(response.status).toBe(400) + const data = await response.json() + expect(data.error).toBe('Cannot create circular folder reference') + expect(mockTxUpdate).not.toHaveBeenCalled() + }) + it('rejects a batch that would form a cycle', async () => { mockWhere .mockReturnValueOnce([ diff --git a/apps/sim/app/api/folders/reorder/route.ts b/apps/sim/app/api/folders/reorder/route.ts index b361abf6df1..b0f7062b968 100644 --- a/apps/sim/app/api/folders/reorder/route.ts +++ b/apps/sim/app/api/folders/reorder/route.ts @@ -1,14 +1,17 @@ import { db } from '@sim/db' -import { workflowFolder } from '@sim/db/schema' +import { folder } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { assertFolderMutable, FolderLockedError } from '@sim/platform-authz/workflow' -import { eq, inArray } from 'drizzle-orm' +import { ResourceLockedError } from '@sim/platform-authz/resource-lock' +import { and, eq, inArray, isNull } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { reorderFoldersContract } from '@/lib/api/contracts' import { parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { performReorderFolders } from '@/lib/folders/orchestration' +import { FOLDER_RESOURCE_POLICIES } from '@/lib/folders/policy' +import { statusForOrchestrationError } from '@/lib/workflows/orchestration/types' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' const logger = createLogger('FolderReorderAPI') @@ -37,65 +40,71 @@ export const PUT = withRouteHandler(async (req: NextRequest) => { const folderIds = updates.map((u) => u.id) const existingFolders = await db - .select({ id: workflowFolder.id, workspaceId: workflowFolder.workspaceId }) - .from(workflowFolder) - .where(inArray(workflowFolder.id, folderIds)) - - const validIds = new Set( - existingFolders.filter((f) => f.workspaceId === workspaceId).map((f) => f.id) - ) - - const validUpdates = updates.filter((u) => validIds.has(u.id)) - - if (validUpdates.length === 0) { - return NextResponse.json({ error: 'No valid folders to update' }, { status: 400 }) + .select({ + id: folder.id, + workspaceId: folder.workspaceId, + resourceType: folder.resourceType, + }) + .from(folder) + .where(and(inArray(folder.id, folderIds), isNull(folder.deletedAt))) + + const validRows = existingFolders.filter((f) => f.workspaceId === workspaceId) + const validIds = new Set(validRows.map((f) => f.id)) + const resourceTypeById = new Map(validRows.map((f) => [f.id, f.resourceType])) + + // Any id that doesn't resolve to an existing, active, same-workspace folder fails + // the whole batch up front rather than silently reordering a subset and reporting + // success with a smaller `updated` count. + const hasInvalidId = updates.some((u) => !validIds.has(u.id)) + if (hasInvalidId) { + return NextResponse.json({ error: 'One or more folders were not found' }, { status: 400 }) } - - const targetParentIds = Array.from( - new Set(validUpdates.map((u) => u.parentId).filter((id): id is string => Boolean(id))) - ) - - if (targetParentIds.length > 0) { - const parentFolders = await db - .select({ - id: workflowFolder.id, - workspaceId: workflowFolder.workspaceId, - archivedAt: workflowFolder.archivedAt, - }) - .from(workflowFolder) - .where(inArray(workflowFolder.id, targetParentIds)) - - const validParentIds = new Set( - parentFolders.filter((f) => f.workspaceId === workspaceId && !f.archivedAt).map((f) => f.id) + // A single reorder call operates on one resourceType at a time (the UI never mixes + // folder types in one drag-drop tree). Reject a mixed-type batch explicitly instead + // of silently reordering only the first-seen type and reporting success — a caller + // bug that sends folders from two resource types should surface as an error, not a + // partial `updated` count with no indication some entries were skipped. + const resourceType = resourceTypeById.get(updates[0].id)! + const hasMixedResourceTypes = updates.some((u) => resourceTypeById.get(u.id) !== resourceType) + if (hasMixedResourceTypes) { + return NextResponse.json( + { error: 'All folders in a reorder batch must share the same resourceType' }, + { status: 400 } ) + } - for (const update of validUpdates) { - if (!update.parentId) continue - if (update.parentId === update.id) { - return NextResponse.json({ error: 'Folder cannot be its own parent' }, { status: 400 }) - } - if (!validParentIds.has(update.parentId)) { - return NextResponse.json({ error: 'Parent folder not found' }, { status: 400 }) - } + // Parent-id existence/workspace/resourceType/deleted validity is re-checked by + // `performReorderFolders` (via `assertFolderParentValid`) below — this route does + // not duplicate that check, it only guards the self-parent case which is a + // cheap synchronous comparison, not a DB round-trip. + for (const update of updates) { + if (update.parentId && update.parentId === update.id) { + return NextResponse.json({ error: 'Folder cannot be its own parent' }, { status: 400 }) } } const workspaceFolders = await db - .select({ id: workflowFolder.id, parentId: workflowFolder.parentId }) - .from(workflowFolder) - .where(eq(workflowFolder.workspaceId, workspaceId)) + .select({ id: folder.id, parentId: folder.parentId }) + .from(folder) + .where( + and( + eq(folder.workspaceId, workspaceId), + eq(folder.resourceType, resourceType), + isNull(folder.deletedAt) + ) + ) const parentById = new Map() - for (const folder of workspaceFolders) { - parentById.set(folder.id, folder.parentId) + for (const folderRow of workspaceFolders) { + parentById.set(folderRow.id, folderRow.parentId) } - for (const update of validUpdates) { + for (const update of updates) { if (update.parentId !== undefined) { parentById.set(update.id, update.parentId || null) } } - for (const update of validUpdates) { + for (const update of updates) { const visited = new Set() let cursor: string | null = update.id while (cursor) { @@ -110,33 +119,32 @@ export const PUT = withRouteHandler(async (req: NextRequest) => { } } - for (const update of validUpdates) { - await assertFolderMutable(update.id) + const policy = FOLDER_RESOURCE_POLICIES[resourceType] + for (const update of updates) { + await policy.assertMutable(update.id) if (update.parentId !== undefined) { - await assertFolderMutable(update.parentId) + await policy.assertMutable(update.parentId) } } - await db.transaction(async (tx) => { - for (const update of validUpdates) { - const updateData: Record = { - sortOrder: update.sortOrder, - updatedAt: new Date(), - } - if (update.parentId !== undefined) { - updateData.parentId = update.parentId || null - } - await tx.update(workflowFolder).set(updateData).where(eq(workflowFolder.id, update.id)) - } + const result = await performReorderFolders({ + resourceType, + workspaceId, + updates, }) - logger.info( - `[${requestId}] Reordered ${validUpdates.length} folders in workspace ${workspaceId}` - ) + if (!result.success) { + return NextResponse.json( + { error: result.error ?? 'No valid folders to update' }, + { status: statusForOrchestrationError(result.errorCode) } + ) + } + + logger.info(`[${requestId}] Reordered ${result.updated} folders in workspace ${workspaceId}`) - return NextResponse.json({ success: true, updated: validUpdates.length }) + return NextResponse.json({ success: true, updated: result.updated }) } catch (error) { - if (error instanceof FolderLockedError) { + if (error instanceof ResourceLockedError) { return NextResponse.json({ error: error.message }, { status: error.status }) } diff --git a/apps/sim/app/api/folders/route.test.ts b/apps/sim/app/api/folders/route.test.ts index f7eb512da68..d62b800ca60 100644 --- a/apps/sim/app/api/folders/route.test.ts +++ b/apps/sim/app/api/folders/route.test.ts @@ -9,7 +9,7 @@ import { createMockRequest, permissionsMock, permissionsMockFns, - workflowAuthzMockFns, + resourceLockMockFns, } from '@sim/testing' import { drizzleOrmMock } from '@sim/testing/mocks' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -50,9 +50,7 @@ const mockDb = db as any interface CapturedFolderValues { name?: string - color?: string parentId?: string | null - isExpanded?: boolean sortOrder?: number updatedAt?: Date } @@ -64,11 +62,20 @@ function createMockTransaction(mockData: { }) { const { selectResults = [[], []], insertResult = [], onInsertValues } = mockData return async (callback: (tx: unknown) => Promise) => { + // Chainable + directly awaitable: `where(...)` alone resolves to the queued + // result, and `.for('update').limit(1)` (the parent-lock recheck added + // alongside the insert) resolves to the same result without a separate call. + function chainable(result: unknown[]) { + const thenable: any = Promise.resolve(result) + thenable.for = vi.fn().mockReturnValue(thenable) + thenable.limit = vi.fn().mockReturnValue(thenable) + return thenable + } const where = vi.fn() for (const result of selectResults) { - where.mockReturnValueOnce(result) + where.mockReturnValueOnce(chainable(result)) } - where.mockReturnValue([]) + where.mockReturnValue(chainable([])) const tx = { select: vi.fn().mockReturnValue({ @@ -99,24 +106,22 @@ describe('Folders API Route', () => { const mockFolders = [ { id: 'folder-1', + resourceType: 'workflow', name: 'Test Folder 1', userId: 'user-123', workspaceId: 'workspace-123', parentId: null, - color: '#6B7280', - isExpanded: true, sortOrder: 0, createdAt: new Date('2023-01-01T00:00:00.000Z'), updatedAt: new Date('2023-01-01T00:00:00.000Z'), }, { id: 'folder-2', + resourceType: 'workflow', name: 'Test Folder 2', userId: 'user-123', workspaceId: 'workspace-123', parentId: 'folder-1', - color: '#EF4444', - isExpanded: false, sortOrder: 1, createdAt: new Date('2023-01-02T00:00:00.000Z'), updatedAt: new Date('2023-01-02T00:00:00.000Z'), @@ -177,7 +182,7 @@ describe('Folders API Route', () => { 'GET', undefined, {}, - 'http://localhost:3000/api/folders?workspaceId=workspace-123' + 'http://localhost:3000/api/folders?workspaceId=workspace-123&resourceType=workflow' ) const response = await GET(mockRequest) @@ -201,7 +206,7 @@ describe('Folders API Route', () => { 'GET', undefined, {}, - 'http://localhost:3000/api/folders?workspaceId=workspace-123' + 'http://localhost:3000/api/folders?workspaceId=workspace-123&resourceType=workflow' ) const response = await GET(mockRequest) @@ -239,7 +244,7 @@ describe('Folders API Route', () => { 'GET', undefined, {}, - 'http://localhost:3000/api/folders?workspaceId=workspace-123' + 'http://localhost:3000/api/folders?workspaceId=workspace-123&resourceType=workflow' ) const response = await GET(mockRequest) @@ -258,7 +263,7 @@ describe('Folders API Route', () => { 'GET', undefined, {}, - 'http://localhost:3000/api/folders?workspaceId=workspace-123' + 'http://localhost:3000/api/folders?workspaceId=workspace-123&resourceType=workflow' ) const response = await GET(mockRequest) @@ -280,7 +285,7 @@ describe('Folders API Route', () => { 'GET', undefined, {}, - 'http://localhost:3000/api/folders?workspaceId=workspace-123' + 'http://localhost:3000/api/folders?workspaceId=workspace-123&resourceType=workflow' ) const response = await GET(mockRequest) @@ -307,9 +312,9 @@ describe('Folders API Route', () => { ) const req = createMockRequest('POST', { + resourceType: 'workflow', name: 'New Test Folder', workspaceId: 'workspace-123', - color: '#6B7280', }) const response = await POST(req) @@ -347,6 +352,7 @@ describe('Folders API Route', () => { mockReturning.mockReturnValueOnce([{ ...mockFolders[0], sortOrder: 1 }]) const req = createMockRequest('POST', { + resourceType: 'workflow', name: 'New Test Folder', workspaceId: 'workspace-123', }) @@ -368,7 +374,10 @@ describe('Folders API Route', () => { mockTransaction.mockImplementationOnce( createMockTransaction({ - selectResults: [[], []], + // First entry is the in-transaction FOR UPDATE recheck that the target + // parent is still active (a plain row match is enough for the mock; + // production only reads `id`). + selectResults: [[{ id: 'folder-1' }], []], insertResult: [{ ...mockFolders[1] }], }) ) @@ -376,6 +385,7 @@ describe('Folders API Route', () => { mockReturning.mockReturnValueOnce([{ ...mockFolders[1] }]) const req = createMockRequest('POST', { + resourceType: 'workflow', name: 'Subfolder', workspaceId: 'workspace-123', parentId: 'folder-1', @@ -394,12 +404,13 @@ describe('Folders API Route', () => { it('should reject creating a subfolder inside a locked parent folder', async () => { mockAuthenticatedUser() - const { FolderLockedError } = await import('@sim/platform-authz/workflow') - workflowAuthzMockFns.mockAssertFolderMutable.mockRejectedValueOnce( - new FolderLockedError('Folder is locked') + const { ResourceLockedError } = await import('@sim/platform-authz/resource-lock') + resourceLockMockFns.mockAssertFolderMutable.mockRejectedValueOnce( + new ResourceLockedError('workflow', false, 'Folder is locked') ) const req = createMockRequest('POST', { + resourceType: 'workflow', name: 'Subfolder', workspaceId: 'workspace-123', parentId: 'locked-folder', @@ -417,6 +428,7 @@ describe('Folders API Route', () => { mockLimit.mockReturnValueOnce([]) const req = createMockRequest('POST', { + resourceType: 'workflow', name: 'Subfolder', workspaceId: 'workspace-123', parentId: 'folder-in-other-workspace', @@ -433,6 +445,7 @@ describe('Folders API Route', () => { mockUnauthenticated() const req = createMockRequest('POST', { + resourceType: 'workflow', name: 'Test Folder', workspaceId: 'workspace-123', }) @@ -450,6 +463,7 @@ describe('Folders API Route', () => { mockGetUserEntityPermissions.mockResolvedValue('read') const req = createMockRequest('POST', { + resourceType: 'workflow', name: 'Test Folder', workspaceId: 'workspace-123', }) @@ -474,6 +488,7 @@ describe('Folders API Route', () => { ) const req = createMockRequest('POST', { + resourceType: 'workflow', name: 'Test Folder', workspaceId: 'workspace-123', }) @@ -498,6 +513,7 @@ describe('Folders API Route', () => { ) const req = createMockRequest('POST', { + resourceType: 'workflow', name: 'Test Folder', workspaceId: 'workspace-123', }) @@ -512,10 +528,10 @@ describe('Folders API Route', () => { it('should return 400 when required fields are missing', async () => { const testCases = [ - { name: '', workspaceId: 'workspace-123' }, - { name: 'Test Folder', workspaceId: '' }, - { workspaceId: 'workspace-123' }, - { name: 'Test Folder' }, + { resourceType: 'workflow', name: '', workspaceId: 'workspace-123' }, + { resourceType: 'workflow', name: 'Test Folder', workspaceId: '' }, + { resourceType: 'workflow', workspaceId: 'workspace-123' }, + { resourceType: 'workflow', name: 'Test Folder' }, ] for (const body of testCases) { @@ -540,6 +556,7 @@ describe('Folders API Route', () => { }) const req = createMockRequest('POST', { + resourceType: 'workflow', name: 'Test Folder', workspaceId: 'workspace-123', }) @@ -575,6 +592,7 @@ describe('Folders API Route', () => { }) const req = createMockRequest('POST', { + resourceType: 'workflow', name: ' Test Folder With Spaces ', workspaceId: 'workspace-123', }) @@ -584,35 +602,5 @@ describe('Folders API Route', () => { expect(capturedValues).not.toBeNull() expect(capturedValues!.name).toBe('Test Folder With Spaces') }) - - it('should use default color when not provided', async () => { - mockAuthenticatedUser() - - let capturedValues: CapturedFolderValues | null = null - - mockTransaction.mockImplementationOnce( - createMockTransaction({ - selectResults: [[], []], - insertResult: [mockFolders[0]], - onInsertValues: (values) => { - capturedValues = values - }, - }) - ) - mockValues.mockImplementationOnce((values: CapturedFolderValues) => { - capturedValues = values - return { returning: mockReturning } - }) - - const req = createMockRequest('POST', { - name: 'Test Folder', - workspaceId: 'workspace-123', - }) - - await POST(req) - - expect(capturedValues).not.toBeNull() - expect(capturedValues!.color).toBe('#6B7280') - }) }) }) diff --git a/apps/sim/app/api/folders/route.ts b/apps/sim/app/api/folders/route.ts index 6310d7f4696..f7eaf39db27 100644 --- a/apps/sim/app/api/folders/route.ts +++ b/apps/sim/app/api/folders/route.ts @@ -1,25 +1,19 @@ import { createLogger } from '@sim/logger' -import { assertFolderMutable, FolderLockedError } from '@sim/platform-authz/workflow' +import { ResourceLockedError } from '@sim/platform-authz/resource-lock' import { type NextRequest, NextResponse } from 'next/server' import { createFolderContract, listFoldersContract } from '@/lib/api/contracts' import { parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { performCreateFolder } from '@/lib/folders/orchestration' +import { FOLDER_RESOURCE_POLICIES } from '@/lib/folders/policy' import { listFoldersForWorkspace } from '@/lib/folders/queries' import { captureServerEvent } from '@/lib/posthog/server' -import { performCreateFolder } from '@/lib/workflows/orchestration' +import { statusForOrchestrationError } from '@/lib/workflows/orchestration/types' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' const logger = createLogger('FoldersAPI') -function folderMutationStatus(errorCode: string | undefined): number { - if (errorCode === 'validation') return 400 - if (errorCode === 'conflict') return 409 - if (errorCode === 'not_found') return 404 - return 500 -} - -// GET - Fetch folders for a workspace export const GET = withRouteHandler(async (request: NextRequest) => { try { const session = await getSession() @@ -29,9 +23,8 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const parsed = await parseRequest(listFoldersContract, request, {}) if (!parsed.success) return parsed.response - const { workspaceId, scope } = parsed.data.query + const { workspaceId, resourceType, scope } = parsed.data.query - // Check if user has workspace permissions const workspacePermission = await getUserEntityPermissions( session.user.id, 'workspace', @@ -42,7 +35,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: 'Access denied to this workspace' }, { status: 403 }) } - const folders = await listFoldersForWorkspace(workspaceId, scope) + const folders = await listFoldersForWorkspace(workspaceId, resourceType, scope) return NextResponse.json({ folders }) } catch (error) { @@ -51,7 +44,6 @@ export const GET = withRouteHandler(async (request: NextRequest) => { } }) -// POST - Create a new folder export const POST = withRouteHandler(async (request: NextRequest) => { try { const session = await getSession() @@ -63,10 +55,10 @@ export const POST = withRouteHandler(async (request: NextRequest) => { if (!parsed.success) return parsed.response const { id: clientId, + resourceType, name, workspaceId, parentId, - color, sortOrder: providedSortOrder, } = parsed.data.body @@ -83,22 +75,22 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } - await assertFolderMutable(parentId ?? null) + await FOLDER_RESOURCE_POLICIES[resourceType].assertMutable(parentId ?? null) const result = await performCreateFolder({ id: clientId, + resourceType, userId: session.user.id, workspaceId, name, parentId, - color, sortOrder: providedSortOrder, }) if (!result.success || !result.folder) { return NextResponse.json( { error: result.error }, - { status: folderMutationStatus(result.errorCode) } + { status: statusForOrchestrationError(result.errorCode) } ) } @@ -115,7 +107,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ folder: newFolder }) } catch (error) { - if (error instanceof FolderLockedError) { + if (error instanceof ResourceLockedError) { return NextResponse.json({ error: error.message }, { status: error.status }) } logger.error('Error creating folder:', { error }) diff --git a/apps/sim/app/api/knowledge/[id]/restore/route.ts b/apps/sim/app/api/knowledge/[id]/restore/route.ts index 5dee08582a6..0aaf658dcf7 100644 --- a/apps/sim/app/api/knowledge/[id]/restore/route.ts +++ b/apps/sim/app/api/knowledge/[id]/restore/route.ts @@ -49,7 +49,13 @@ export const POST = withRouteHandler( }) if (!result.success) { const status = - result.errorCode === 'not_found' ? 404 : result.errorCode === 'conflict' ? 409 : 500 + result.errorCode === 'not_found' + ? 404 + : result.errorCode === 'conflict' + ? 409 + : result.errorCode === 'locked' + ? 423 + : 500 return NextResponse.json({ error: result.error }, { status }) } diff --git a/apps/sim/app/api/knowledge/[id]/route.ts b/apps/sim/app/api/knowledge/[id]/route.ts index 34a85d1a684..4ef1e99f8cb 100644 --- a/apps/sim/app/api/knowledge/[id]/route.ts +++ b/apps/sim/app/api/knowledge/[id]/route.ts @@ -1,5 +1,6 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' +import { ResourceLockedError } from '@sim/platform-authz/resource-lock' import { type NextRequest, NextResponse } from 'next/server' import { updateKnowledgeBaseContract } from '@/lib/api/contracts/knowledge' import { parseRequest } from '@/lib/api/server' @@ -12,8 +13,10 @@ import { getKnowledgeBaseById, KnowledgeBaseConflictError, KnowledgeBasePermissionError, + KnowledgeBaseValidationError, updateKnowledgeBase, } from '@/lib/knowledge/service' +import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' import { checkKnowledgeBaseAccess, checkKnowledgeBaseWriteAccess } from '@/app/api/knowledge/utils' const logger = createLogger('KnowledgeBaseByIdAPI') @@ -94,13 +97,32 @@ export const PUT = withRouteHandler( const validatedData = parsed.data.body + if (validatedData.locked !== undefined) { + const currentWorkspaceId = accessCheck.knowledgeBase.workspaceId + const targetWorkspaceId = validatedData.workspaceId ?? currentWorkspaceId + const workspaceIdsToCheck = new Set( + [currentWorkspaceId, targetWorkspaceId].filter((wsId): wsId is string => Boolean(wsId)) + ) + for (const wsId of workspaceIdsToCheck) { + const workspacePermission = await getUserEntityPermissions(userId, 'workspace', wsId) + if (workspacePermission !== 'admin') { + return NextResponse.json( + { error: 'Admin access required to lock knowledge bases' }, + { status: 403 } + ) + } + } + } + const updatedKnowledgeBase = await updateKnowledgeBase( id, { name: validatedData.name, description: validatedData.description, workspaceId: validatedData.workspaceId, + folderId: validatedData.folderId, chunkingConfig: validatedData.chunkingConfig, + locked: validatedData.locked, }, requestId, { actorUserId: userId } @@ -140,6 +162,9 @@ export const PUT = withRouteHandler( data: updatedKnowledgeBase, }) } catch (error) { + if (error instanceof ResourceLockedError) { + return NextResponse.json({ error: error.message }, { status: error.status }) + } if (error instanceof KnowledgeBaseConflictError) { return NextResponse.json({ error: error.message }, { status: 409 }) } @@ -147,6 +172,9 @@ export const PUT = withRouteHandler( logger.warn(`[${requestId}] Forbidden knowledge base update on ${id}: ${error.message}`) return NextResponse.json({ error: error.message }, { status: 403 }) } + if (error instanceof KnowledgeBaseValidationError) { + return NextResponse.json({ error: error.message }, { status: 400 }) + } logger.error(`[${requestId}] Error updating knowledge base`, error) return NextResponse.json({ error: 'Failed to update knowledge base' }, { status: 500 }) @@ -213,6 +241,9 @@ export const DELETE = withRouteHandler( data: { message: 'Knowledge base deleted successfully' }, }) } catch (error) { + if (error instanceof ResourceLockedError) { + return NextResponse.json({ error: error.message }, { status: error.status }) + } logger.error(`[${requestId}] Error deleting knowledge base`, error) return NextResponse.json({ error: 'Failed to delete knowledge base' }, { status: 500 }) } diff --git a/apps/sim/app/api/knowledge/route.test.ts b/apps/sim/app/api/knowledge/route.test.ts index 4ad2aad2acf..0a8c212b658 100644 --- a/apps/sim/app/api/knowledge/route.test.ts +++ b/apps/sim/app/api/knowledge/route.test.ts @@ -13,7 +13,7 @@ import { import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const { mockDbChain } = vi.hoisted(() => { - const mockDbChain = { + const mockDbChain: Record = { select: vi.fn().mockReturnThis(), from: vi.fn().mockReturnThis(), leftJoin: vi.fn().mockReturnThis(), @@ -24,6 +24,7 @@ const { mockDbChain } = vi.hoisted(() => { insert: vi.fn().mockReturnThis(), values: vi.fn().mockResolvedValue(undefined), } + mockDbChain.transaction = vi.fn((cb: (tx: typeof mockDbChain) => unknown) => cb(mockDbChain)) return { mockDbChain } }) @@ -44,11 +45,19 @@ describe('Knowledge Base API Route', () => { Object.values(mockDbChain).forEach((fn) => { if (typeof fn === 'function') { fn.mockClear() - if (fn !== mockDbChain.orderBy && fn !== mockDbChain.values && fn !== mockDbChain.limit) { + if ( + fn !== mockDbChain.orderBy && + fn !== mockDbChain.values && + fn !== mockDbChain.limit && + fn !== mockDbChain.transaction + ) { fn.mockReturnThis() } } }) + mockDbChain.transaction.mockImplementation((cb: (tx: typeof mockDbChain) => unknown) => + cb(mockDbChain) + ) permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('admin') diff --git a/apps/sim/app/api/knowledge/route.ts b/apps/sim/app/api/knowledge/route.ts index e14efd14656..f0027e961f9 100644 --- a/apps/sim/app/api/knowledge/route.ts +++ b/apps/sim/app/api/knowledge/route.ts @@ -1,5 +1,6 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' +import { ResourceLockedError } from '@sim/platform-authz/resource-lock' import { type NextRequest, NextResponse } from 'next/server' import { createKnowledgeBaseContract, @@ -17,6 +18,7 @@ import { KnowledgeBaseConflictError, KnowledgeBasePermissionError, type KnowledgeBaseScope, + KnowledgeBaseValidationError, } from '@/lib/knowledge/service' import { captureServerEvent } from '@/lib/posthog/server' @@ -164,6 +166,12 @@ export const POST = withRouteHandler(async (req: NextRequest) => { logger.warn(`[${requestId}] Forbidden knowledge base creation: ${createError.message}`) return NextResponse.json({ error: createError.message }, { status: 403 }) } + if (createError instanceof KnowledgeBaseValidationError) { + return NextResponse.json({ error: createError.message }, { status: 400 }) + } + if (createError instanceof ResourceLockedError) { + return NextResponse.json({ error: createError.message }, { status: createError.status }) + } throw createError } } catch (error) { diff --git a/apps/sim/app/api/pinned-items/[resourceType]/[resourceId]/route.test.ts b/apps/sim/app/api/pinned-items/[resourceType]/[resourceId]/route.test.ts new file mode 100644 index 00000000000..3351b608dd1 --- /dev/null +++ b/apps/sim/app/api/pinned-items/[resourceType]/[resourceId]/route.test.ts @@ -0,0 +1,139 @@ +/** + * Tests for the pinned-item delete route (/api/pinned-items/[resourceType]/[resourceId]) + * + * @vitest-environment node + */ +import { authMockFns, createMockRequest, schemaMock } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockLogger, mockDb } = vi.hoisted(() => { + const logger = { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + trace: vi.fn(), + fatal: vi.fn(), + child: vi.fn(), + } + return { + mockLogger: logger, + mockDb: { delete: vi.fn() }, + } +}) + +vi.mock('@sim/logger', () => ({ + createLogger: vi.fn().mockReturnValue(mockLogger), + runWithRequestContext: (_ctx: unknown, fn: () => T): T => fn(), + getRequestContext: () => undefined, +})) +// The route imports `pinnedItem` (a table schema object) from `@sim/db` +// alongside `db` — merge in `schemaMock`'s table shapes like the sibling +// pinned-items/route.test.ts does. +vi.mock('@sim/db', () => ({ db: mockDb, ...schemaMock })) + +import { DELETE } from '@/app/api/pinned-items/[resourceType]/[resourceId]/route' + +const OWNER_USER = { id: 'user-123', email: 'owner@example.com', name: 'Owner User' } +const OTHER_USER = { id: 'user-456', email: 'other@example.com', name: 'Other User' } + +/** One pinned row: `resourceType`='workflow', `resourceId`='workflow-1', owned by OWNER_USER. */ +const ownerPinnedRow = { id: 'pinned-1' } + +function makeParams(resourceType: string, resourceId: string) { + return Promise.resolve({ resourceType, resourceId }) +} + +describe('Pinned Item Delete Route', () => { + const mockDelete = mockDb.delete + const mockWhere = vi.fn() + const mockReturning = vi.fn() + + function mockAuthenticatedUser(user: typeof OWNER_USER = OWNER_USER) { + authMockFns.mockGetSession.mockResolvedValue({ user }) + } + + function mockUnauthenticated() { + authMockFns.mockGetSession.mockResolvedValue(null) + } + + beforeEach(() => { + vi.clearAllMocks() + + mockDelete.mockReturnValue({ where: mockWhere }) + mockWhere.mockReturnValue({ returning: mockReturning }) + mockReturning.mockReturnValue([]) + }) + + it('should unpin a resource successfully', async () => { + mockAuthenticatedUser(OWNER_USER) + mockReturning.mockReturnValueOnce([ownerPinnedRow]) + + const req = createMockRequest('DELETE') + const response = await DELETE(req, { params: makeParams('workflow', 'workflow-1') }) + + expect(response.status).toBe(200) + const data = await response.json() + expect(data).toHaveProperty('success', true) + expect(mockLogger.info).toHaveBeenCalledWith('Unpinned resource', { + resourceType: 'workflow', + resourceId: 'workflow-1', + userId: OWNER_USER.id, + }) + }) + + it('should scope the delete by the caller userId, not by workspace, and never delete another user pin of the same resource', async () => { + // Simulate the query condition by asserting the delete is filtered with the + // requesting user's id, and that a mismatched-owner deletion yields zero rows. + mockAuthenticatedUser(OTHER_USER) + // Because the composite where() is keyed on (userId, resourceType, resourceId), + // OTHER_USER's delete of OWNER_USER's pin matches nothing -> empty returning(). + mockReturning.mockReturnValueOnce([]) + + const req = createMockRequest('DELETE') + const response = await DELETE(req, { params: makeParams('workflow', 'workflow-1') }) + + expect(response.status).toBe(404) + const data = await response.json() + expect(data).toHaveProperty('error', 'Pinned item not found') + + // The delete must always be scoped to the authenticated caller's own userId. + const { eq } = await import('drizzle-orm') + expect(eq).toHaveBeenCalledWith(expect.anything(), OTHER_USER.id) + expect(mockLogger.info).not.toHaveBeenCalled() + }) + + it('should return 401 for unauthenticated requests', async () => { + mockUnauthenticated() + + const req = createMockRequest('DELETE') + const response = await DELETE(req, { params: makeParams('workflow', 'workflow-1') }) + + expect(response.status).toBe(401) + const data = await response.json() + expect(data).toHaveProperty('error', 'Unauthorized') + expect(mockDelete).not.toHaveBeenCalled() + }) + + it('should return 404 when the pin does not exist', async () => { + mockAuthenticatedUser(OWNER_USER) + mockReturning.mockReturnValueOnce([]) + + const req = createMockRequest('DELETE') + const response = await DELETE(req, { params: makeParams('workflow', 'workflow-missing') }) + + expect(response.status).toBe(404) + const data = await response.json() + expect(data).toHaveProperty('error', 'Pinned item not found') + }) + + it('should return 400 for an invalid resourceType path segment', async () => { + mockAuthenticatedUser(OWNER_USER) + + const req = createMockRequest('DELETE') + const response = await DELETE(req, { params: makeParams('not-a-real-type', 'workflow-1') }) + + expect(response.status).toBe(400) + expect(mockDelete).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/pinned-items/[resourceType]/[resourceId]/route.ts b/apps/sim/app/api/pinned-items/[resourceType]/[resourceId]/route.ts new file mode 100644 index 00000000000..ba7c2892238 --- /dev/null +++ b/apps/sim/app/api/pinned-items/[resourceType]/[resourceId]/route.ts @@ -0,0 +1,43 @@ +import { db, pinnedItem } from '@sim/db' +import { createLogger } from '@sim/logger' +import { and, eq } from 'drizzle-orm' +import { type NextRequest, NextResponse } from 'next/server' +import { deletePinnedItemContract } from '@/lib/api/contracts' +import { parseRequest } from '@/lib/api/server' +import { getSession } from '@/lib/auth' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' + +const logger = createLogger('PinnedItemDeleteAPI') + +type RouteContext = { params: Promise<{ resourceType: string; resourceId: string }> } + +/** Unpins a resource, identified by the composite key (`resourceType`, `resourceId`). */ +export const DELETE = withRouteHandler(async (request: NextRequest, context: RouteContext) => { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseRequest(deletePinnedItemContract, request, context) + if (!parsed.success) return parsed.response + const { resourceType, resourceId } = parsed.data.params + + const deleted = await db + .delete(pinnedItem) + .where( + and( + eq(pinnedItem.userId, session.user.id), + eq(pinnedItem.resourceType, resourceType), + eq(pinnedItem.resourceId, resourceId) + ) + ) + .returning({ id: pinnedItem.id }) + + if (deleted.length === 0) { + return NextResponse.json({ error: 'Pinned item not found' }, { status: 404 }) + } + + logger.info('Unpinned resource', { resourceType, resourceId, userId: session.user.id }) + + return NextResponse.json({ success: true }) +}) diff --git a/apps/sim/app/api/pinned-items/route.test.ts b/apps/sim/app/api/pinned-items/route.test.ts new file mode 100644 index 00000000000..8fcfe345488 --- /dev/null +++ b/apps/sim/app/api/pinned-items/route.test.ts @@ -0,0 +1,399 @@ +/** + * Tests for pinned-items API route + * + * @vitest-environment node + */ +import { + authMockFns, + createMockRequest, + permissionsMock, + permissionsMockFns, + schemaMock, +} from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockLogger, mockDb } = vi.hoisted(() => { + const logger = { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + trace: vi.fn(), + fatal: vi.fn(), + child: vi.fn(), + } + return { + mockLogger: logger, + mockDb: { select: vi.fn(), insert: vi.fn() }, + } +}) + +const mockGetUserEntityPermissions = permissionsMockFns.mockGetUserEntityPermissions + +vi.mock('@sim/logger', () => ({ + createLogger: vi.fn().mockReturnValue(mockLogger), + runWithRequestContext: (_ctx: unknown, fn: () => T): T => fn(), + getRequestContext: () => undefined, +})) +vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock) +// The route imports both the db client AND the table schema objects +// (folder, workflow, knowledgeBase, userTableDefinitions, workspaceFiles, +// pinnedItem) from `@sim/db` — the global `databaseMock` only covers the +// client, so this mock merges in `schemaMock`'s table shapes as well. +vi.mock('@sim/db', () => ({ db: mockDb, ...schemaMock })) + +import { GET, POST } from '@/app/api/pinned-items/route' + +const defaultMockUser = { + id: 'user-123', + email: 'test@example.com', + name: 'Test User', +} + +const mockPinnedWorkflowRow = { + id: 'pinned-1', + userId: 'user-123', + workspaceId: 'workspace-123', + resourceType: 'workflow', + resourceId: 'workflow-1', + pinnedAt: new Date('2024-01-01T00:00:00.000Z'), +} + +const mockPinnedFolderRow = { + id: 'pinned-2', + userId: 'user-123', + workspaceId: 'workspace-123', + resourceType: 'folder', + resourceId: 'folder-1', + pinnedAt: new Date('2024-01-02T00:00:00.000Z'), +} + +describe('Pinned Items API Route', () => { + const mockSelect = mockDb.select + const mockFrom = vi.fn() + const mockWhere = vi.fn() + const mockLimit = vi.fn() + const mockInsert = mockDb.insert + const mockValues = vi.fn() + const mockReturning = vi.fn() + + function mockAuthenticatedUser() { + authMockFns.mockGetSession.mockResolvedValue({ user: defaultMockUser }) + } + + function mockUnauthenticated() { + authMockFns.mockGetSession.mockResolvedValue(null) + } + + /** Configures the resourceExistsInWorkspace() lookup chain to resolve `exists`. */ + function mockResourceExists(exists: boolean) { + mockLimit.mockReturnValueOnce(exists ? [{ id: 'resource-1' }] : []) + } + + beforeEach(() => { + vi.clearAllMocks() + + mockSelect.mockReturnValue({ from: mockFrom }) + mockFrom.mockReturnValue({ where: mockWhere }) + const defaultWhereResult = [] as Array> & { + limit: typeof mockLimit + } + defaultWhereResult.limit = mockLimit + mockWhere.mockReturnValue(defaultWhereResult) + mockLimit.mockReturnValue([{ id: 'resource-1' }]) + + mockInsert.mockReturnValue({ values: mockValues }) + mockValues.mockReturnValue({ returning: mockReturning }) + mockReturning.mockReturnValue([mockPinnedWorkflowRow]) + + mockGetUserEntityPermissions.mockResolvedValue('write') + }) + + describe('GET /api/pinned-items', () => { + it('should list pinned items for a workspace', async () => { + mockAuthenticatedUser() + // 1st .where(): the pinned_item list query. 2nd/3rd: the per-resourceType + // active-resource existence check filterActivePinnedItems() batches (one + // per distinct type present — workflow, then folder, in row order). + mockWhere + .mockReturnValueOnce([mockPinnedWorkflowRow, mockPinnedFolderRow]) + .mockReturnValueOnce([{ id: 'workflow-1' }]) + .mockReturnValueOnce([{ id: 'folder-1' }]) + + const req = createMockRequest( + 'GET', + undefined, + {}, + 'http://localhost:3000/api/pinned-items?workspaceId=workspace-123' + ) + + const response = await GET(req) + + expect(response.status).toBe(200) + const data = await response.json() + expect(data.pinnedItems).toHaveLength(2) + expect(data.pinnedItems[0]).toMatchObject({ + id: 'pinned-1', + resourceType: 'workflow', + resourceId: 'workflow-1', + pinnedAt: '2024-01-01T00:00:00.000Z', + }) + }) + + it('should filter by resourceType when provided', async () => { + mockAuthenticatedUser() + // 1st .where(): the pinned_item list query (folder-only). 2nd: the + // filterActivePinnedItems() existence check for the single type present. + mockWhere.mockReturnValueOnce([mockPinnedFolderRow]).mockReturnValueOnce([{ id: 'folder-1' }]) + + const req = createMockRequest( + 'GET', + undefined, + {}, + 'http://localhost:3000/api/pinned-items?workspaceId=workspace-123&resourceType=folder' + ) + + const response = await GET(req) + + expect(response.status).toBe(200) + const data = await response.json() + expect(data.pinnedItems).toHaveLength(1) + expect(data.pinnedItems[0]).toMatchObject({ resourceType: 'folder', resourceId: 'folder-1' }) + }) + + it('excludes a pin whose underlying resource has since been deleted/archived', async () => { + mockAuthenticatedUser() + // The pinned_item row for the workflow pin still exists, but the + // workflow itself is gone (empty existence-check result) — the stale + // pin must not be returned. The folder pin's resource is still active. + mockWhere + .mockReturnValueOnce([mockPinnedWorkflowRow, mockPinnedFolderRow]) + .mockReturnValueOnce([]) + .mockReturnValueOnce([{ id: 'folder-1' }]) + + const req = createMockRequest( + 'GET', + undefined, + {}, + 'http://localhost:3000/api/pinned-items?workspaceId=workspace-123' + ) + + const response = await GET(req) + + expect(response.status).toBe(200) + const data = await response.json() + expect(data.pinnedItems).toHaveLength(1) + expect(data.pinnedItems[0]).toMatchObject({ resourceType: 'folder', resourceId: 'folder-1' }) + }) + + it('should return 401 for unauthenticated requests', async () => { + mockUnauthenticated() + + const req = createMockRequest( + 'GET', + undefined, + {}, + 'http://localhost:3000/api/pinned-items?workspaceId=workspace-123' + ) + + const response = await GET(req) + + expect(response.status).toBe(401) + const data = await response.json() + expect(data).toHaveProperty('error', 'Unauthorized') + }) + + it('should return 400 when workspaceId is missing', async () => { + mockAuthenticatedUser() + + const req = createMockRequest('GET', undefined, {}, 'http://localhost:3000/api/pinned-items') + + const response = await GET(req) + + expect(response.status).toBe(400) + }) + + it('should return 403 when user has no workspace permissions', async () => { + mockAuthenticatedUser() + mockGetUserEntityPermissions.mockResolvedValue(null) + + const req = createMockRequest( + 'GET', + undefined, + {}, + 'http://localhost:3000/api/pinned-items?workspaceId=workspace-123' + ) + + const response = await GET(req) + + expect(response.status).toBe(403) + const data = await response.json() + expect(data).toHaveProperty('error', 'Access denied to this workspace') + }) + }) + + describe('POST /api/pinned-items', () => { + it('should pin a workflow successfully', async () => { + mockAuthenticatedUser() + mockResourceExists(true) + mockReturning.mockReturnValueOnce([mockPinnedWorkflowRow]) + + const req = createMockRequest('POST', { + workspaceId: 'workspace-123', + resourceType: 'workflow', + resourceId: 'workflow-1', + }) + + const response = await POST(req) + + expect(response.status).toBe(201) + const data = await response.json() + expect(data.pinnedItem).toMatchObject({ + id: 'pinned-1', + resourceType: 'workflow', + resourceId: 'workflow-1', + }) + }) + + it('should pin a folder successfully', async () => { + mockAuthenticatedUser() + mockResourceExists(true) + mockReturning.mockReturnValueOnce([mockPinnedFolderRow]) + + const req = createMockRequest('POST', { + workspaceId: 'workspace-123', + resourceType: 'folder', + resourceId: 'folder-1', + }) + + const response = await POST(req) + + expect(response.status).toBe(201) + const data = await response.json() + expect(data.pinnedItem).toMatchObject({ + id: 'pinned-2', + resourceType: 'folder', + resourceId: 'folder-1', + }) + }) + + it('should return 401 for unauthenticated requests', async () => { + mockUnauthenticated() + + const req = createMockRequest('POST', { + workspaceId: 'workspace-123', + resourceType: 'workflow', + resourceId: 'workflow-1', + }) + + const response = await POST(req) + + expect(response.status).toBe(401) + const data = await response.json() + expect(data).toHaveProperty('error', 'Unauthorized') + }) + + it('should return 403 when user has no workspace permissions', async () => { + mockAuthenticatedUser() + mockGetUserEntityPermissions.mockResolvedValue(null) + + const req = createMockRequest('POST', { + workspaceId: 'workspace-123', + resourceType: 'workflow', + resourceId: 'workflow-1', + }) + + const response = await POST(req) + + expect(response.status).toBe(403) + const data = await response.json() + expect(data).toHaveProperty('error', 'Access denied to this workspace') + }) + + it('should return 404 when the resource does not exist in the target workspace', async () => { + mockAuthenticatedUser() + mockResourceExists(false) + + const req = createMockRequest('POST', { + workspaceId: 'workspace-123', + resourceType: 'workflow', + resourceId: 'workflow-in-other-workspace', + }) + + const response = await POST(req) + + expect(response.status).toBe(404) + const data = await response.json() + expect(data).toHaveProperty('error', 'Resource not found in this workspace') + expect(mockInsert).not.toHaveBeenCalled() + }) + + it('should return 409 when the resource is already pinned by this user', async () => { + mockAuthenticatedUser() + mockResourceExists(true) + + const conflictError = Object.assign(new Error('duplicate key value'), { code: '23505' }) + mockValues.mockImplementationOnce(() => { + throw conflictError + }) + + const req = createMockRequest('POST', { + workspaceId: 'workspace-123', + resourceType: 'workflow', + resourceId: 'workflow-1', + }) + + const response = await POST(req) + + expect(response.status).toBe(409) + const data = await response.json() + expect(data).toHaveProperty('error', 'This item is already pinned') + }) + + it('should return 500 and log on an unexpected database error', async () => { + mockAuthenticatedUser() + mockResourceExists(true) + + const dbError = new Error('connection lost') + mockValues.mockImplementationOnce(() => { + throw dbError + }) + + const req = createMockRequest('POST', { + workspaceId: 'workspace-123', + resourceType: 'workflow', + resourceId: 'workflow-1', + }) + + const response = await POST(req) + + expect(response.status).toBe(500) + const data = await response.json() + expect(data).toHaveProperty('error', 'Internal server error') + expect(mockLogger.error).toHaveBeenCalledWith('Error creating pinned item', { + error: dbError, + }) + }) + + it('should return 400 when required fields are missing', async () => { + const testCases = [ + { workspaceId: 'workspace-123', resourceType: 'workflow', resourceId: '' }, + { workspaceId: '', resourceType: 'workflow', resourceId: 'workflow-1' }, + { workspaceId: 'workspace-123', resourceId: 'workflow-1' }, + { workspaceId: 'workspace-123', resourceType: 'not-a-real-type', resourceId: 'workflow-1' }, + ] + + for (const body of testCases) { + mockAuthenticatedUser() + + const req = createMockRequest('POST', body) + + const response = await POST(req) + + expect(response.status).toBe(400) + const data = await response.json() + expect(data).toHaveProperty('error', 'Validation error') + } + }) + }) +}) diff --git a/apps/sim/app/api/pinned-items/route.ts b/apps/sim/app/api/pinned-items/route.ts new file mode 100644 index 00000000000..17ae01362ab --- /dev/null +++ b/apps/sim/app/api/pinned-items/route.ts @@ -0,0 +1,218 @@ +import { + db, + folder, + knowledgeBase, + pinnedItem, + userTableDefinitions, + workflow, + workspaceFiles, +} from '@sim/db' +import { createLogger } from '@sim/logger' +import { getPostgresErrorCode } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { and, eq, inArray, isNull } from 'drizzle-orm' +import type { PgColumn, PgTable } from 'drizzle-orm/pg-core' +import { type NextRequest, NextResponse } from 'next/server' +import type { PinnedResourceType } from '@/lib/api/contracts' +import { createPinnedItemContract, listPinnedItemsContract } from '@/lib/api/contracts' +import { parseRequest } from '@/lib/api/server' +import { getSession } from '@/lib/auth' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' + +const logger = createLogger('PinnedItemsAPI') + +/** + * Per-resourceType table/column wiring for {@link resourceExistsInWorkspace}. + * Mirrors the `FolderCascadeConfig` pattern in `lib/folders/orchestration.ts` + * — the delta between resource types is just which table/columns to query, + * so it's captured as data instead of one copy-pasted switch-case per type. + */ +interface PinnedResourceLookupConfig { + resourceTable: PgTable + idColumn: PgColumn + workspaceColumn: PgColumn + /** Soft-delete timestamp column; the resource is active when this is null. */ + deletedColumn: PgColumn +} + +const PINNED_RESOURCE_LOOKUP: Record = { + folder: { + resourceTable: folder, + idColumn: folder.id, + workspaceColumn: folder.workspaceId, + deletedColumn: folder.deletedAt, + }, + workflow: { + resourceTable: workflow, + idColumn: workflow.id, + workspaceColumn: workflow.workspaceId, + deletedColumn: workflow.archivedAt, + }, + file: { + resourceTable: workspaceFiles, + idColumn: workspaceFiles.id, + workspaceColumn: workspaceFiles.workspaceId, + deletedColumn: workspaceFiles.deletedAt, + }, + knowledge_base: { + resourceTable: knowledgeBase, + idColumn: knowledgeBase.id, + workspaceColumn: knowledgeBase.workspaceId, + deletedColumn: knowledgeBase.deletedAt, + }, + table: { + resourceTable: userTableDefinitions, + idColumn: userTableDefinitions.id, + workspaceColumn: userTableDefinitions.workspaceId, + deletedColumn: userTableDefinitions.archivedAt, + }, +} + +/** + * Verifies `resourceId` actually exists, belongs to `workspaceId`, and is not + * soft-deleted — otherwise a pin can be created pointing at a nonexistent or + * cross-workspace resource. Mirrors the workspace-scoped existence checks the + * `/api/folders` routes already perform. + */ +async function resourceExistsInWorkspace( + resourceType: PinnedResourceType, + resourceId: string, + workspaceId: string +): Promise { + const config = PINNED_RESOURCE_LOOKUP[resourceType] + const [row] = await db + .select({ id: config.idColumn }) + .from(config.resourceTable) + .where( + and( + eq(config.idColumn, resourceId), + eq(config.workspaceColumn, workspaceId), + isNull(config.deletedColumn) + ) + ) + .limit(1) + return Boolean(row) +} + +function toPinnedItemApi(row: typeof pinnedItem.$inferSelect) { + return { ...row, pinnedAt: row.pinnedAt.toISOString() } +} + +/** + * Drops pins whose underlying resource has since been deleted/archived — + * without this, a pin outlives its resource forever (the resource's own + * delete never touches `pinned_item`), and a consumer that renders pins + * without cross-referencing the live resource list would show a phantom + * entry. Batches one existence query per distinct resourceType present in + * `rows` (not one per row) to stay O(types) instead of O(n). + */ +async function filterActivePinnedItems( + rows: (typeof pinnedItem.$inferSelect)[], + workspaceId: string +): Promise<(typeof pinnedItem.$inferSelect)[]> { + const idsByType = new Map() + for (const row of rows) { + const type = row.resourceType as PinnedResourceType + const ids = idsByType.get(type) ?? [] + ids.push(row.resourceId) + idsByType.set(type, ids) + } + + const activeIdsByType = new Map>() + await Promise.all( + Array.from(idsByType.entries()).map(async ([type, ids]) => { + const config = PINNED_RESOURCE_LOOKUP[type] + const activeRows = await db + .select({ id: config.idColumn }) + .from(config.resourceTable) + .where( + and( + inArray(config.idColumn, ids), + eq(config.workspaceColumn, workspaceId), + isNull(config.deletedColumn) + ) + ) + activeIdsByType.set(type, new Set(activeRows.map((r) => r.id as string))) + }) + ) + + return rows.filter((row) => + activeIdsByType.get(row.resourceType as PinnedResourceType)?.has(row.resourceId) + ) +} + +/** Lists pinned items for a workspace, optionally filtered to a single `resourceType`. */ +export const GET = withRouteHandler(async (request: NextRequest) => { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseRequest(listPinnedItemsContract, request, {}) + if (!parsed.success) return parsed.response + const { workspaceId, resourceType } = parsed.data.query + + const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId) + if (!permission) { + return NextResponse.json({ error: 'Access denied to this workspace' }, { status: 403 }) + } + + const rows = await db + .select() + .from(pinnedItem) + .where( + resourceType + ? and( + eq(pinnedItem.userId, session.user.id), + eq(pinnedItem.workspaceId, workspaceId), + eq(pinnedItem.resourceType, resourceType) + ) + : and(eq(pinnedItem.userId, session.user.id), eq(pinnedItem.workspaceId, workspaceId)) + ) + + const activeRows = await filterActivePinnedItems(rows, workspaceId) + return NextResponse.json({ pinnedItems: activeRows.map(toPinnedItemApi) }) +}) + +export const POST = withRouteHandler(async (request: NextRequest) => { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseRequest(createPinnedItemContract, request, {}) + if (!parsed.success) return parsed.response + const { workspaceId, resourceType, resourceId } = parsed.data.body + + const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId) + if (!permission) { + return NextResponse.json({ error: 'Access denied to this workspace' }, { status: 403 }) + } + + const exists = await resourceExistsInWorkspace(resourceType, resourceId, workspaceId) + if (!exists) { + return NextResponse.json({ error: 'Resource not found in this workspace' }, { status: 404 }) + } + + try { + const [created] = await db + .insert(pinnedItem) + .values({ + id: generateId(), + userId: session.user.id, + workspaceId, + resourceType, + resourceId, + }) + .returning() + + return NextResponse.json({ pinnedItem: toPinnedItemApi(created) }, { status: 201 }) + } catch (error) { + if (getPostgresErrorCode(error) === '23505') { + return NextResponse.json({ error: 'This item is already pinned' }, { status: 409 }) + } + logger.error('Error creating pinned item', { error }) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } +}) diff --git a/apps/sim/app/api/table/[tableId]/restore/route.ts b/apps/sim/app/api/table/[tableId]/restore/route.ts index 066b03480c1..216e3dadb7e 100644 --- a/apps/sim/app/api/table/[tableId]/restore/route.ts +++ b/apps/sim/app/api/table/[tableId]/restore/route.ts @@ -35,7 +35,13 @@ export const POST = withRouteHandler( const result = await performRestoreTable({ tableId, userId: auth.userId, requestId }) if (!result.success) { const status = - result.errorCode === 'not_found' ? 404 : result.errorCode === 'conflict' ? 409 : 500 + result.errorCode === 'not_found' + ? 404 + : result.errorCode === 'conflict' + ? 409 + : result.errorCode === 'locked' + ? 423 + : 500 return NextResponse.json({ error: result.error }, { status }) } diff --git a/apps/sim/app/api/table/[tableId]/route.ts b/apps/sim/app/api/table/[tableId]/route.ts index 5d0069fa570..0a587d3fab4 100644 --- a/apps/sim/app/api/table/[tableId]/route.ts +++ b/apps/sim/app/api/table/[tableId]/route.ts @@ -1,4 +1,5 @@ import { createLogger } from '@sim/logger' +import { ResourceLockedError } from '@sim/platform-authz/resource-lock' import { getErrorMessage } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' import { getTableQuerySchema, renameTableContract } from '@/lib/api/contracts/tables' @@ -7,8 +8,15 @@ import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' 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, + renameTable, + TableConflictError, + TableInvalidFolderError, + type TableSchema, +} from '@/lib/table' import { getWorkspaceTableLimits } from '@/lib/table/billing' +import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' import { accessError, checkAccess, normalizeColumn } from '@/app/api/table/utils' const logger = createLogger('TableDetailAPI') @@ -65,6 +73,8 @@ export const GET = withRouteHandler(async (request: NextRequest, { params }: Tab metadata: table.metadata ?? null, rowCount: table.rowCount, maxRows: maxRowsPerTable, + folderId: table.folderId, + locked: table.locked, createdAt: table.createdAt instanceof Date ? table.createdAt.toISOString() @@ -125,16 +135,48 @@ 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.locked !== undefined && validated.locked !== table.locked) { + const workspacePermission = await getUserEntityPermissions( + authResult.userId, + 'workspace', + table.workspaceId + ) + if (workspacePermission !== 'admin') { + return NextResponse.json( + { error: 'Admin access required to lock tables' }, + { status: 403 } + ) + } + } + + const isLockOnlyUpdate = + validated.name === table.name && + (validated.folderId === undefined || validated.folderId === (table.folderId ?? null)) + + const updated = await renameTable( + tableId, + validated.name, + requestId, + authResult.userId, + validated.folderId, + validated.locked, + isLockOnlyUpdate + ) return NextResponse.json({ success: true, data: { table: updated }, }) } catch (error) { + if (error instanceof ResourceLockedError) { + return NextResponse.json({ error: error.message }, { status: error.status }) + } if (error instanceof TableConflictError) { return NextResponse.json({ error: error.message }, { status: 409 }) } + if (error instanceof TableInvalidFolderError) { + return NextResponse.json({ error: error.message }, { status: 400 }) + } logger.error(`[${requestId}] Error renaming table:`, error) return NextResponse.json( @@ -191,6 +233,9 @@ export const DELETE = withRouteHandler( if (isZodError(error)) { return validationErrorResponse(error) } + if (error instanceof ResourceLockedError) { + return NextResponse.json({ error: error.message }, { status: error.status }) + } logger.error(`[${requestId}] Error deleting table:`, error) return NextResponse.json({ error: 'Failed to delete table' }, { status: 500 }) diff --git a/apps/sim/app/api/table/route.ts b/apps/sim/app/api/table/route.ts index 45b530db29a..3d96035da27 100644 --- a/apps/sim/app/api/table/route.ts +++ b/apps/sim/app/api/table/route.ts @@ -1,5 +1,6 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' +import { ResourceLockedError } from '@sim/platform-authz/resource-lock' import { type NextRequest, NextResponse } from 'next/server' import { createTableContract, listTablesQuerySchema } from '@/lib/api/contracts/tables' import { isZodError, parseRequest, validationErrorResponse } from '@/lib/api/server/validation' @@ -11,6 +12,7 @@ import { createTable, getWorkspaceTableLimits, listTables, + TableInvalidFolderError, type TableSchema, type TableScope, } from '@/lib/table' @@ -84,6 +86,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { userId: authResult.userId, maxTables: planLimits.maxTables, initialRowCount: params.initialRowCount, + folderId: params.folderId, }, requestId ) @@ -127,6 +130,8 @@ export const POST = withRouteHandler(async (request: NextRequest) => { }, rowCount: table.rowCount, maxRows: table.maxRows, + folderId: table.folderId, + locked: table.locked, createdAt: table.createdAt instanceof Date ? table.createdAt.toISOString() @@ -140,6 +145,14 @@ export const POST = withRouteHandler(async (request: NextRequest) => { }, }) } catch (error) { + if (error instanceof ResourceLockedError) { + return NextResponse.json({ error: error.message }, { status: error.status }) + } + + if (error instanceof TableInvalidFolderError) { + return NextResponse.json({ error: error.message }, { status: 400 }) + } + if (error instanceof Error) { if (error.message.includes('maximum table limit')) { return NextResponse.json({ error: error.message }, { status: 403 }) @@ -207,6 +220,8 @@ export const GET = withRouteHandler(async (request: NextRequest) => { rowCount: t.rowCount, maxRows: t.maxRows, workspaceId: t.workspaceId, + folderId: t.folderId ?? null, + locked: t.locked, createdBy: t.createdBy, createdAt: t.createdAt instanceof Date ? t.createdAt.toISOString() : String(t.createdAt), updatedAt: t.updatedAt instanceof Date ? t.updatedAt.toISOString() : String(t.updatedAt), diff --git a/apps/sim/app/api/v1/admin/folders/[id]/export/route.ts b/apps/sim/app/api/v1/admin/folders/[id]/export/route.ts index 6591ee39e19..158edcf931c 100644 --- a/apps/sim/app/api/v1/admin/folders/[id]/export/route.ts +++ b/apps/sim/app/api/v1/admin/folders/[id]/export/route.ts @@ -12,9 +12,9 @@ */ import { db } from '@sim/db' -import { workflow, workflowFolder } from '@sim/db/schema' +import { workflow, folder as workflowFolder } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { eq } from 'drizzle-orm' +import { and, eq } from 'drizzle-orm' import { NextResponse } from 'next/server' import { adminV1ExportFolderContract } from '@/lib/api/contracts/v1/admin' import { parseRequest } from '@/lib/api/server' @@ -114,7 +114,7 @@ export const GET = withRouteHandler( workspaceId: workflowFolder.workspaceId, }) .from(workflowFolder) - .where(eq(workflowFolder.id, folderId)) + .where(and(eq(workflowFolder.id, folderId), eq(workflowFolder.resourceType, 'workflow'))) .limit(1) if (!folderData) { @@ -133,7 +133,12 @@ export const GET = withRouteHandler( parentId: workflowFolder.parentId, }) .from(workflowFolder) - .where(eq(workflowFolder.workspaceId, folderData.workspaceId)) + .where( + and( + eq(workflowFolder.workspaceId, folderData.workspaceId), + eq(workflowFolder.resourceType, 'workflow') + ) + ) const workflowsInFolder = collectWorkflowsInFolder(folderId, allWorkflows, allFolders) const subfolders = collectSubfolders(folderId, allFolders) diff --git a/apps/sim/app/api/v1/admin/types.ts b/apps/sim/app/api/v1/admin/types.ts index 04f61a8a964..d315de3a529 100644 --- a/apps/sim/app/api/v1/admin/types.ts +++ b/apps/sim/app/api/v1/admin/types.ts @@ -7,13 +7,13 @@ import type { auditLog, + folder, member, organization, subscription, user, userStats, workflow, - workflowFolder, workspace, } from '@sim/db/schema' import type { InferSelectModel } from 'drizzle-orm' @@ -27,7 +27,7 @@ import type { BlockState, Loop, Parallel } from '@/stores/workflows/workflow/typ export type DbUser = InferSelectModel export type DbWorkspace = InferSelectModel export type DbWorkflow = InferSelectModel -export type DbWorkflowFolder = InferSelectModel +export type DbWorkflowFolder = InferSelectModel export type DbOrganization = InferSelectModel export type DbSubscription = InferSelectModel export type DbMember = InferSelectModel @@ -156,7 +156,6 @@ export interface AdminFolder { id: string name: string parentId: string | null - color: string | null sortOrder: number createdAt: string updatedAt: string @@ -167,7 +166,6 @@ export function toAdminFolder(dbFolder: DbWorkflowFolder): AdminFolder { id: dbFolder.id, name: dbFolder.name, parentId: dbFolder.parentId, - color: dbFolder.color, sortOrder: dbFolder.sortOrder, createdAt: dbFolder.createdAt.toISOString(), updatedAt: dbFolder.updatedAt.toISOString(), diff --git a/apps/sim/app/api/v1/admin/workspaces/[id]/export/route.ts b/apps/sim/app/api/v1/admin/workspaces/[id]/export/route.ts index e8913c74e0f..9db37ddc02f 100644 --- a/apps/sim/app/api/v1/admin/workspaces/[id]/export/route.ts +++ b/apps/sim/app/api/v1/admin/workspaces/[id]/export/route.ts @@ -13,9 +13,9 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' -import { workflow, workflowFolder, workspace } from '@sim/db/schema' +import { folder, workflow, workspace } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { eq } from 'drizzle-orm' +import { and, eq } from 'drizzle-orm' import { NextResponse } from 'next/server' import { adminV1ExportWorkspaceContract } from '@/lib/api/contracts/v1/admin' import { parseRequest } from '@/lib/api/server' @@ -68,8 +68,8 @@ export const GET = withRouteHandler( const folders = await db .select() - .from(workflowFolder) - .where(eq(workflowFolder.workspaceId, workspaceId)) + .from(folder) + .where(and(eq(folder.workspaceId, workspaceId), eq(folder.resourceType, 'workflow'))) const workflowExports: Array<{ workflow: WorkspaceExportPayload['workflows'][number]['workflow'] diff --git a/apps/sim/app/api/v1/admin/workspaces/[id]/folders/route.ts b/apps/sim/app/api/v1/admin/workspaces/[id]/folders/route.ts index 1b6269efeec..9f49457ae5d 100644 --- a/apps/sim/app/api/v1/admin/workspaces/[id]/folders/route.ts +++ b/apps/sim/app/api/v1/admin/workspaces/[id]/folders/route.ts @@ -11,9 +11,9 @@ */ import { db } from '@sim/db' -import { workflowFolder, workspace } from '@sim/db/schema' +import { folder, workspace } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { count, eq } from 'drizzle-orm' +import { and, count, eq } from 'drizzle-orm' import { adminV1ListWorkspaceFoldersContract } from '@/lib/api/contracts/v1/admin' import { parseRequest } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -46,16 +46,18 @@ export const GET = withRouteHandler( return notFoundResponse('Workspace') } + const workspaceFolderCondition = and( + eq(folder.workspaceId, workspaceId), + eq(folder.resourceType, 'workflow') + ) + const [countResult, folders] = await Promise.all([ - db - .select({ total: count() }) - .from(workflowFolder) - .where(eq(workflowFolder.workspaceId, workspaceId)), + db.select({ total: count() }).from(folder).where(workspaceFolderCondition), db .select() - .from(workflowFolder) - .where(eq(workflowFolder.workspaceId, workspaceId)) - .orderBy(workflowFolder.sortOrder, workflowFolder.name) + .from(folder) + .where(workspaceFolderCondition) + .orderBy(folder.sortOrder, folder.name) .limit(limit) .offset(offset), ]) diff --git a/apps/sim/app/api/v1/admin/workspaces/[id]/import/route.ts b/apps/sim/app/api/v1/admin/workspaces/[id]/import/route.ts index 7bbf9a5ea01..df2977fdb70 100644 --- a/apps/sim/app/api/v1/admin/workspaces/[id]/import/route.ts +++ b/apps/sim/app/api/v1/admin/workspaces/[id]/import/route.ts @@ -24,7 +24,7 @@ */ import { db } from '@sim/db' -import { workflow, workflowFolder } from '@sim/db/schema' +import { folder, workflow } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' @@ -151,8 +151,9 @@ export const POST = withRouteHandler( let rootFolderId: string | undefined if (rootFolderName && createFolders) { rootFolderId = generateId() - await db.insert(workflowFolder).values({ + await db.insert(folder).values({ id: rootFolderId, + resourceType: 'workflow', name: rootFolderName, userId: workspaceData.ownerId, workspaceId, @@ -229,8 +230,9 @@ async function importSingleWorkflow( if (!folderMap.has(fullPath)) { const folderId = generateId() - await db.insert(workflowFolder).values({ + await db.insert(folder).values({ id: folderId, + resourceType: 'workflow', name: wf.folderPath[i], userId: ownerId, workspaceId, diff --git a/apps/sim/app/api/v1/admin/workspaces/[id]/route.ts b/apps/sim/app/api/v1/admin/workspaces/[id]/route.ts index b2ebdd5a82f..0e6e5954dc6 100644 --- a/apps/sim/app/api/v1/admin/workspaces/[id]/route.ts +++ b/apps/sim/app/api/v1/admin/workspaces/[id]/route.ts @@ -7,9 +7,9 @@ */ import { db } from '@sim/db' -import { workflow, workflowFolder, workspace } from '@sim/db/schema' +import { folder, workflow, workspace } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { count, eq } from 'drizzle-orm' +import { and, count, eq } from 'drizzle-orm' import { adminV1GetWorkspaceContract } from '@/lib/api/contracts/v1/admin' import { parseRequest } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -49,8 +49,8 @@ export const GET = withRouteHandler( db.select({ count: count() }).from(workflow).where(eq(workflow.workspaceId, workspaceId)), db .select({ count: count() }) - .from(workflowFolder) - .where(eq(workflowFolder.workspaceId, workspaceId)), + .from(folder) + .where(and(eq(folder.workspaceId, workspaceId), eq(folder.resourceType, 'workflow'))), ]) const data: AdminWorkspaceDetail = { diff --git a/apps/sim/app/api/workspaces/[id]/files/[fileId]/restore/route.ts b/apps/sim/app/api/workspaces/[id]/files/[fileId]/restore/route.ts index 0d41810c77e..41f8dcf7792 100644 --- a/apps/sim/app/api/workspaces/[id]/files/[fileId]/restore/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/[fileId]/restore/route.ts @@ -6,7 +6,10 @@ import { getValidationErrorMessage } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { performRestoreWorkspaceFile } from '@/lib/workspace-files/orchestration' +import { + performRestoreWorkspaceFile, + workspaceFilesOrchestrationStatus, +} from '@/lib/workspace-files/orchestration' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' const logger = createLogger('RestoreWorkspaceFileAPI') @@ -46,7 +49,7 @@ export const POST = withRouteHandler( if (!result.success) { return NextResponse.json( { error: result.error }, - { status: result.errorCode === 'conflict' ? 409 : 500 } + { status: workspaceFilesOrchestrationStatus(result.errorCode) } ) } diff --git a/apps/sim/app/api/workspaces/[id]/files/[fileId]/route.ts b/apps/sim/app/api/workspaces/[id]/files/[fileId]/route.ts index 1826988ea08..ce165f2161b 100644 --- a/apps/sim/app/api/workspaces/[id]/files/[fileId]/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/[fileId]/route.ts @@ -10,9 +10,11 @@ import { getSession } from '@/lib/auth' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { captureServerEvent } from '@/lib/posthog/server' +import { getWorkspaceFile } from '@/lib/uploads/contexts/workspace' import { performDeleteWorkspaceFileItems, performRenameWorkspaceFile, + workspaceFilesOrchestrationStatus, } from '@/lib/workspace-files/orchestration' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' @@ -37,7 +39,7 @@ export const PATCH = withRouteHandler( const parsed = await parseRequest(renameWorkspaceFileContract, request, context) if (!parsed.success) return parsed.response const { id: workspaceId, fileId } = parsed.data.params - const { name } = parsed.data.body + const { name, locked } = parsed.data.body const userPermission = await getUserEntityPermissions( session.user.id, @@ -51,16 +53,32 @@ export const PATCH = withRouteHandler( return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) } + const existingFile = await getWorkspaceFile(workspaceId, fileId) + if (!existingFile) { + return NextResponse.json({ success: false, error: 'File not found' }, { status: 404 }) + } + + if (locked !== undefined && locked !== existingFile.locked && userPermission !== 'admin') { + return NextResponse.json( + { success: false, error: 'Admin access required to lock files' }, + { status: 403 } + ) + } + + const isLockOnlyUpdate = name === existingFile.name + const result = await performRenameWorkspaceFile({ workspaceId, fileId, name, userId: session.user.id, + locked, + isLockOnlyUpdate, }) if (!result.success || !result.file) { return NextResponse.json( { success: false, error: result.error }, - { status: result.errorCode === 'conflict' ? 409 : 500 } + { status: workspaceFilesOrchestrationStatus(result.errorCode) } ) } @@ -132,14 +150,7 @@ export const DELETE = withRouteHandler( if (!result.success) { return NextResponse.json( { success: false, error: result.error }, - { - status: - result.errorCode === 'validation' - ? 400 - : result.errorCode === 'not_found' - ? 404 - : 500, - } + { status: workspaceFilesOrchestrationStatus(result.errorCode) } ) } diff --git a/apps/sim/app/api/workspaces/[id]/files/bulk-archive/route.test.ts b/apps/sim/app/api/workspaces/[id]/files/bulk-archive/route.test.ts new file mode 100644 index 00000000000..1e5412b6f52 --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/files/bulk-archive/route.test.ts @@ -0,0 +1,96 @@ +/** + * @vitest-environment node + * + * Thin route-level smoke test: confirms `POST /api/workspaces/[id]/files/bulk-archive` + * maps an orchestration `errorCode: 'locked'` (a folder or file lock hit inside + * `performDeleteWorkspaceFileItems`) to a 423 response, rather than exercising + * the lock logic itself — that's covered at the orchestration layer in + * `lib/workspace-files/orchestration/file-folder-lifecycle.test.ts`. + */ +import { authMockFns, permissionsMock, permissionsMockFns, posthogServerMock } from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockPerformDeleteWorkspaceFileItems } = vi.hoisted(() => ({ + mockPerformDeleteWorkspaceFileItems: vi.fn(), +})) + +vi.mock('@/lib/workspace-files/orchestration', () => ({ + performDeleteWorkspaceFileItems: mockPerformDeleteWorkspaceFileItems, + workspaceFilesOrchestrationStatus: ( + errorCode: 'validation' | 'not_found' | 'conflict' | 'locked' | 'internal' | undefined + ) => { + if (errorCode === 'validation') return 400 + if (errorCode === 'conflict') return 409 + if (errorCode === 'not_found') return 404 + if (errorCode === 'locked') return 423 + return 500 + }, +})) + +vi.mock('@/lib/posthog/server', () => posthogServerMock) +vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock) + +const WS = '7727ef3f-8cf6-4686-b063-2bb006a10785' + +import { POST } from '@/app/api/workspaces/[id]/files/bulk-archive/route' + +const params = (id = WS) => ({ params: Promise.resolve({ id }) }) + +const makeRequest = (body: unknown) => + new NextRequest(`http://localhost/api/workspaces/${WS}/files/bulk-archive`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + +describe('bulk-archive route', () => { + beforeEach(() => { + vi.clearAllMocks() + authMockFns.mockGetSession.mockResolvedValue({ + user: { id: 'user-1', name: 'User One', email: 'u@example.com' }, + }) + permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('write') + }) + + it('returns 401 when unauthenticated', async () => { + authMockFns.mockGetSession.mockResolvedValueOnce(null) + const res = await POST(makeRequest({ fileIds: ['file-1'] }), params()) + expect(res.status).toBe(401) + }) + + it('returns 403 for a caller without write access', async () => { + permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValueOnce('read') + const res = await POST(makeRequest({ fileIds: ['file-1'] }), params()) + expect(res.status).toBe(403) + expect(mockPerformDeleteWorkspaceFileItems).not.toHaveBeenCalled() + }) + + it('returns 423 when the orchestration layer reports a lock (folder or file)', async () => { + mockPerformDeleteWorkspaceFileItems.mockResolvedValueOnce({ + success: false, + error: 'File is locked by its containing folder', + errorCode: 'locked', + }) + + const res = await POST(makeRequest({ folderIds: ['folder-1'] }), params()) + + expect(res.status).toBe(423) + expect(await res.json()).toEqual({ + success: false, + error: 'File is locked by its containing folder', + }) + }) + + it('archives successfully when nothing is locked', async () => { + mockPerformDeleteWorkspaceFileItems.mockResolvedValueOnce({ + success: true, + deletedItems: { files: 1, folders: 0 }, + }) + + const res = await POST(makeRequest({ fileIds: ['file-1'] }), params()) + + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ success: true, deletedItems: { files: 1, folders: 0 } }) + }) +}) diff --git a/apps/sim/app/api/workspaces/[id]/files/bulk-archive/route.ts b/apps/sim/app/api/workspaces/[id]/files/bulk-archive/route.ts index 1f47f650bd6..a3d0daf6fe7 100644 --- a/apps/sim/app/api/workspaces/[id]/files/bulk-archive/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/bulk-archive/route.ts @@ -5,7 +5,10 @@ import { parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { captureServerEvent } from '@/lib/posthog/server' -import { performDeleteWorkspaceFileItems } from '@/lib/workspace-files/orchestration' +import { + performDeleteWorkspaceFileItems, + workspaceFilesOrchestrationStatus, +} from '@/lib/workspace-files/orchestration' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' const logger = createLogger('WorkspaceFileBulkArchiveAPI') @@ -37,14 +40,7 @@ export const POST = withRouteHandler( if (!result.success) { return NextResponse.json( { success: false, error: result.error }, - { - status: - result.errorCode === 'validation' - ? 400 - : result.errorCode === 'not_found' - ? 404 - : 500, - } + { status: workspaceFilesOrchestrationStatus(result.errorCode) } ) } if (!result.deletedItems) { diff --git a/apps/sim/app/api/workspaces/[id]/files/download/route.ts b/apps/sim/app/api/workspaces/[id]/files/download/route.ts index b6220308e55..78bb8afd08d 100644 --- a/apps/sim/app/api/workspaces/[id]/files/download/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/download/route.ts @@ -6,6 +6,7 @@ import { downloadWorkspaceFileItemsContract } from '@/lib/api/contracts/workspac import { parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import type { FolderSubtreeRow } from '@/lib/folders/subtree' import { captureServerEvent } from '@/lib/posthog/server' import { buildWorkspaceFileFolderPathMap, @@ -42,21 +43,32 @@ function withZipPathSuffix(path: string, suffix: number): string { : `${directory}${filename} (${suffix})` } -function collectDescendantFolderIds( - selectedFolderIds: string[], - folders: Array<{ id: string; parentId: string | null }> -): Set { - const folderIds = new Set(selectedFolderIds) - let changed = true - while (changed) { - changed = false - for (const folder of folders) { - if (folder.parentId && folderIds.has(folder.parentId) && !folderIds.has(folder.id)) { - folderIds.add(folder.id) - changed = true - } +/** + * Unions each root folder id with every descendant reachable from it. + * Builds the parent→children index once and reuses it across all roots, + * instead of rebuilding it per root as a per-root `collectDescendantFolderIds` + * call would. + */ +function collectSelectedFolderIds(rootIds: string[], folders: FolderSubtreeRow[]): Set { + const childrenByParent = new Map() + for (const folder of folders) { + if (!folder.parentId) continue + const children = childrenByParent.get(folder.parentId) ?? [] + children.push(folder.id) + childrenByParent.set(folder.parentId, children) + } + + const folderIds = new Set(rootIds) + const visit = (id: string) => { + for (const childId of childrenByParent.get(id) ?? []) { + if (folderIds.has(childId)) continue + folderIds.add(childId) + visit(childId) } } + for (const rootId of rootIds) { + visit(rootId) + } return folderIds } @@ -83,7 +95,7 @@ export const GET = withRouteHandler( listWorkspaceFileFolders(workspaceId), ]) const folderPaths = buildWorkspaceFileFolderPathMap(folders) - const selectedFolderIds = collectDescendantFolderIds(folderIds, folders) + const selectedFolderIds = collectSelectedFolderIds(folderIds, folders) const requestedFileIds = new Set(fileIds) const filesToZip = files.filter( (file) => diff --git a/apps/sim/app/api/workspaces/[id]/files/folders/[folderId]/restore/route.ts b/apps/sim/app/api/workspaces/[id]/files/folders/[folderId]/restore/route.ts deleted file mode 100644 index 86df6e83f91..00000000000 --- a/apps/sim/app/api/workspaces/[id]/files/folders/[folderId]/restore/route.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { createLogger } from '@sim/logger' -import { type NextRequest, NextResponse } from 'next/server' -import { restoreWorkspaceFileFolderContract } from '@/lib/api/contracts/workspace-file-folders' -import { parseRequest } from '@/lib/api/server' -import { getSession } from '@/lib/auth' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { captureServerEvent } from '@/lib/posthog/server' -import { - performRestoreWorkspaceFileFolder, - workspaceFilesOrchestrationStatus, -} from '@/lib/workspace-files/orchestration' -import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' - -const logger = createLogger('WorkspaceFileFolderRestoreAPI') - -export const POST = withRouteHandler( - async (request: NextRequest, context: { params: Promise<{ id: string; folderId: string }> }) => { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const parsed = await parseRequest(restoreWorkspaceFileFolderContract, request, context) - if (!parsed.success) return parsed.response - const { id: workspaceId, folderId } = parsed.data.params - - const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId) - if (permission !== 'admin' && permission !== 'write') { - return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) - } - - try { - const result = await performRestoreWorkspaceFileFolder({ - workspaceId, - folderId, - userId: session.user.id, - }) - if (!result.success) { - return NextResponse.json( - { success: false, error: result.error }, - { status: workspaceFilesOrchestrationStatus(result.errorCode) } - ) - } - const { folder, restoredItems } = result - if (!folder || !restoredItems) { - return NextResponse.json( - { success: false, error: 'Failed to restore workspace file folder' }, - { status: 500 } - ) - } - - logger.info(`Restored workspace file folder: ${folderId}`) - - captureServerEvent( - session.user.id, - 'folder_restored', - { folder_id: folderId, workspace_id: workspaceId }, - { groups: { workspace: workspaceId } } - ) - return NextResponse.json({ success: true, folder, restoredItems }) - } catch (error) { - logger.error('Failed to restore workspace file folder:', error) - return NextResponse.json({ success: false, error: 'Internal server error' }, { status: 500 }) - } - } -) diff --git a/apps/sim/app/api/workspaces/[id]/files/folders/[folderId]/route.ts b/apps/sim/app/api/workspaces/[id]/files/folders/[folderId]/route.ts deleted file mode 100644 index 78232e8a704..00000000000 --- a/apps/sim/app/api/workspaces/[id]/files/folders/[folderId]/route.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { createLogger } from '@sim/logger' -import { type NextRequest, NextResponse } from 'next/server' -import { - deleteWorkspaceFileFolderContract, - updateWorkspaceFileFolderContract, -} from '@/lib/api/contracts/workspace-file-folders' -import { parseRequest } from '@/lib/api/server' -import { getSession } from '@/lib/auth' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { captureServerEvent } from '@/lib/posthog/server' -import { - performDeleteWorkspaceFileItems, - performUpdateWorkspaceFileFolder, - workspaceFilesOrchestrationStatus, -} from '@/lib/workspace-files/orchestration' -import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' - -const logger = createLogger('WorkspaceFileFolderAPI') - -async function assertWritePermission(userId: string, workspaceId: string) { - const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId) - return permission === 'admin' || permission === 'write' -} - -export const PATCH = withRouteHandler( - async (request: NextRequest, context: { params: Promise<{ id: string; folderId: string }> }) => { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const parsed = await parseRequest(updateWorkspaceFileFolderContract, request, context) - if (!parsed.success) return parsed.response - const { id: workspaceId, folderId } = parsed.data.params - - if (!(await assertWritePermission(session.user.id, workspaceId))) { - return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) - } - - try { - const result = await performUpdateWorkspaceFileFolder({ - workspaceId, - folderId, - userId: session.user.id, - ...parsed.data.body, - }) - if (!result.success || !result.folder) { - return NextResponse.json( - { success: false, error: result.error }, - { status: workspaceFilesOrchestrationStatus(result.errorCode) } - ) - } - captureServerEvent( - session.user.id, - 'folder_renamed', - { workspace_id: workspaceId }, - { groups: { workspace: workspaceId } } - ) - return NextResponse.json({ success: true, folder: result.folder }) - } catch (error) { - logger.error('Failed to update workspace file folder:', error) - return NextResponse.json({ success: false, error: 'Internal server error' }, { status: 500 }) - } - } -) - -export const DELETE = withRouteHandler( - async (request: NextRequest, context: { params: Promise<{ id: string; folderId: string }> }) => { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const parsed = await parseRequest(deleteWorkspaceFileFolderContract, request, context) - if (!parsed.success) return parsed.response - const { id: workspaceId, folderId } = parsed.data.params - - if (!(await assertWritePermission(session.user.id, workspaceId))) { - return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) - } - - try { - const result = await performDeleteWorkspaceFileItems({ - workspaceId, - userId: session.user.id, - folderIds: [folderId], - }) - if (!result.success) { - return NextResponse.json( - { success: false, error: result.error }, - { - status: - result.errorCode === 'validation' - ? 400 - : result.errorCode === 'not_found' - ? 404 - : 500, - } - ) - } - if (!result.deletedItems) { - return NextResponse.json( - { success: false, error: 'Failed to delete workspace file folder' }, - { status: 500 } - ) - } - - captureServerEvent( - session.user.id, - 'folder_deleted', - { workspace_id: workspaceId }, - { groups: { workspace: workspaceId } } - ) - - return NextResponse.json({ success: true, deletedItems: result.deletedItems }) - } catch (error) { - logger.error('Failed to delete workspace file folder:', error) - return NextResponse.json({ success: false, error: 'Internal server error' }, { status: 500 }) - } - } -) diff --git a/apps/sim/app/api/workspaces/[id]/files/folders/route.ts b/apps/sim/app/api/workspaces/[id]/files/folders/route.ts deleted file mode 100644 index 02de14dcf1e..00000000000 --- a/apps/sim/app/api/workspaces/[id]/files/folders/route.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { createLogger } from '@sim/logger' -import { type NextRequest, NextResponse } from 'next/server' -import { - createWorkspaceFileFolderContract, - listWorkspaceFileFoldersContract, -} from '@/lib/api/contracts/workspace-file-folders' -import { parseRequest } from '@/lib/api/server' -import { getSession } from '@/lib/auth' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { captureServerEvent } from '@/lib/posthog/server' -import { listWorkspaceFileFolders } from '@/lib/uploads/contexts/workspace' -import { - performCreateWorkspaceFileFolder, - workspaceFilesOrchestrationStatus, -} from '@/lib/workspace-files/orchestration' -import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' - -const logger = createLogger('WorkspaceFileFoldersAPI') - -async function getWorkspacePermission(userId: string, workspaceId: string) { - return getUserEntityPermissions(userId, 'workspace', workspaceId) -} - -export const GET = withRouteHandler( - async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const parsed = await parseRequest(listWorkspaceFileFoldersContract, request, context) - if (!parsed.success) return parsed.response - const { id: workspaceId } = parsed.data.params - const { scope } = parsed.data.query - - const permission = await getWorkspacePermission(session.user.id, workspaceId) - if (!permission) { - return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) - } - - const folders = await listWorkspaceFileFolders(workspaceId, { scope }) - return NextResponse.json({ success: true, folders }) - } -) - -export const POST = withRouteHandler( - async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const parsed = await parseRequest(createWorkspaceFileFolderContract, request, context) - if (!parsed.success) return parsed.response - const { id: workspaceId } = parsed.data.params - const { name, parentId } = parsed.data.body - - const permission = await getWorkspacePermission(session.user.id, workspaceId) - if (permission !== 'admin' && permission !== 'write') { - return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) - } - - try { - const result = await performCreateWorkspaceFileFolder({ - workspaceId, - userId: session.user.id, - name, - parentId, - }) - if (!result.success || !result.folder) { - return NextResponse.json( - { success: false, error: result.error }, - { status: workspaceFilesOrchestrationStatus(result.errorCode) } - ) - } - captureServerEvent( - session.user.id, - 'folder_created', - { workspace_id: workspaceId }, - { groups: { workspace: workspaceId } } - ) - return NextResponse.json({ success: true, folder: result.folder }) - } catch (error) { - logger.error('Failed to create workspace file folder:', error) - return NextResponse.json({ success: false, error: 'Internal server error' }, { status: 500 }) - } - } -) diff --git a/apps/sim/app/api/workspaces/[id]/files/move/route.test.ts b/apps/sim/app/api/workspaces/[id]/files/move/route.test.ts new file mode 100644 index 00000000000..655d574a2c4 --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/files/move/route.test.ts @@ -0,0 +1,105 @@ +/** + * @vitest-environment node + * + * Thin route-level smoke test: confirms `POST /api/workspaces/[id]/files/move` + * maps an orchestration `errorCode: 'locked'` (a folder or file lock hit + * inside `performMoveWorkspaceFileItems`) to a 423 response, rather than + * exercising the lock logic itself — that's covered at the orchestration + * layer in `lib/workspace-files/orchestration/file-folder-lifecycle.test.ts`. + */ +import { authMockFns, permissionsMock, permissionsMockFns, posthogServerMock } from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockPerformMoveWorkspaceFileItems } = vi.hoisted(() => ({ + mockPerformMoveWorkspaceFileItems: vi.fn(), +})) + +vi.mock('@/lib/workspace-files/orchestration', () => ({ + performMoveWorkspaceFileItems: mockPerformMoveWorkspaceFileItems, + workspaceFilesOrchestrationStatus: ( + errorCode: 'validation' | 'not_found' | 'conflict' | 'locked' | 'internal' | undefined + ) => { + if (errorCode === 'validation') return 400 + if (errorCode === 'conflict') return 409 + if (errorCode === 'not_found') return 404 + if (errorCode === 'locked') return 423 + return 500 + }, +})) + +vi.mock('@/lib/posthog/server', () => posthogServerMock) +vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock) + +const WS = '7727ef3f-8cf6-4686-b063-2bb006a10785' + +import { POST } from '@/app/api/workspaces/[id]/files/move/route' + +const params = (id = WS) => ({ params: Promise.resolve({ id }) }) + +const makeRequest = (body: unknown) => + new NextRequest(`http://localhost/api/workspaces/${WS}/files/move`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + +describe('move route', () => { + beforeEach(() => { + vi.clearAllMocks() + authMockFns.mockGetSession.mockResolvedValue({ + user: { id: 'user-1', name: 'User One', email: 'u@example.com' }, + }) + permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('write') + }) + + it('returns 401 when unauthenticated', async () => { + authMockFns.mockGetSession.mockResolvedValueOnce(null) + const res = await POST( + makeRequest({ fileIds: ['file-1'], targetFolderId: 'folder-2' }), + params() + ) + expect(res.status).toBe(401) + }) + + it('returns 403 for a caller without write access', async () => { + permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValueOnce('read') + const res = await POST( + makeRequest({ fileIds: ['file-1'], targetFolderId: 'folder-2' }), + params() + ) + expect(res.status).toBe(403) + expect(mockPerformMoveWorkspaceFileItems).not.toHaveBeenCalled() + }) + + it('returns 423 when the orchestration layer reports a lock (folder or file)', async () => { + mockPerformMoveWorkspaceFileItems.mockResolvedValueOnce({ + success: false, + error: 'Folder is locked', + errorCode: 'locked', + }) + + const res = await POST( + makeRequest({ folderIds: ['folder-1'], targetFolderId: 'folder-2' }), + params() + ) + + expect(res.status).toBe(423) + expect(await res.json()).toEqual({ success: false, error: 'Folder is locked' }) + }) + + it('moves successfully when nothing is locked', async () => { + mockPerformMoveWorkspaceFileItems.mockResolvedValueOnce({ + success: true, + movedItems: { files: 1, folders: 0 }, + }) + + const res = await POST( + makeRequest({ fileIds: ['file-1'], targetFolderId: 'folder-2' }), + params() + ) + + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ success: true, movedItems: { files: 1, folders: 0 } }) + }) +}) diff --git a/apps/sim/app/api/workspaces/[id]/files/move/route.ts b/apps/sim/app/api/workspaces/[id]/files/move/route.ts index 81861789eee..53eb6d5f7bf 100644 --- a/apps/sim/app/api/workspaces/[id]/files/move/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/move/route.ts @@ -5,7 +5,10 @@ import { parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { captureServerEvent } from '@/lib/posthog/server' -import { performMoveWorkspaceFileItems } from '@/lib/workspace-files/orchestration' +import { + performMoveWorkspaceFileItems, + workspaceFilesOrchestrationStatus, +} from '@/lib/workspace-files/orchestration' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' const logger = createLogger('WorkspaceFileMoveAPI') @@ -38,16 +41,7 @@ export const POST = withRouteHandler( if (!result.success || !result.movedItems) { return NextResponse.json( { success: false, error: result.error }, - { - status: - result.errorCode === 'conflict' - ? 409 - : result.errorCode === 'not_found' - ? 404 - : result.errorCode === 'validation' - ? 400 - : 500, - } + { status: workspaceFilesOrchestrationStatus(result.errorCode) } ) } if (fileIds.length > 0) { diff --git a/apps/sim/app/api/workspaces/[id]/files/register/route.ts b/apps/sim/app/api/workspaces/[id]/files/register/route.ts index fd29a6c9dfb..4b3bca58ab3 100644 --- a/apps/sim/app/api/workspaces/[id]/files/register/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/register/route.ts @@ -1,5 +1,6 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' +import { ResourceLockedError } from '@sim/platform-authz/resource-lock' import { getErrorMessage } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' import { registerWorkspaceFileContract } from '@/lib/api/contracts/workspace-files' @@ -89,6 +90,10 @@ export const POST = withRouteHandler( return NextResponse.json({ success: true, file: userFile }) } catch (error) { + if (error instanceof ResourceLockedError) { + return NextResponse.json({ success: false, error: error.message }, { status: error.status }) + } + logger.error('Failed to register workspace file:', error) const errorMessage = getErrorMessage(error, 'Failed to register file') diff --git a/apps/sim/app/api/workspaces/[id]/files/route.ts b/apps/sim/app/api/workspaces/[id]/files/route.ts index 6f20d15a213..38198f3113c 100644 --- a/apps/sim/app/api/workspaces/[id]/files/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/route.ts @@ -1,5 +1,6 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' +import { ResourceLockedError } from '@sim/platform-authz/resource-lock' import { getErrorMessage } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' import { @@ -211,6 +212,10 @@ export const POST = withRouteHandler( file: userFile, }) } catch (error) { + if (error instanceof ResourceLockedError) { + return NextResponse.json({ success: false, error: error.message }, { status: error.status }) + } + logger.error(`[${requestId}] Error uploading workspace file:`, error) const errorMessage = getErrorMessage(error, 'Failed to upload file') diff --git a/apps/sim/app/f/[token]/public-file-view.tsx b/apps/sim/app/f/[token]/public-file-view.tsx index 5d1db37b68e..98d28706962 100644 --- a/apps/sim/app/f/[token]/public-file-view.tsx +++ b/apps/sim/app/f/[token]/public-file-view.tsx @@ -58,6 +58,7 @@ export function PublicFileView({ type, uploadedBy: '', folderId: null, + locked: false, uploadedAt: new Date(version), updatedAt: new Date(version), }), diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx index ed960210aa6..e24b95a5dc1 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx @@ -638,7 +638,7 @@ const DataRow = memo(function DataRow({ data-resource-row data-row-id={row.id} className={cn( - 'grid w-full transition-colors', + 'group grid w-full transition-colors', isWindowed && 'absolute top-0 left-0', !isAnyDragActive && 'hover-hover:bg-[var(--surface-3)]', onRowClick && 'cursor-pointer', diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-row-context-menu/file-row-context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-row-context-menu/file-row-context-menu.tsx index f7639dde029..83cb71524c4 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-row-context-menu/file-row-context-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-row-context-menu/file-row-context-menu.tsx @@ -15,7 +15,7 @@ import { FolderInput, Pencil, } from '@sim/emcn' -import { Download, Link, Trash } from '@sim/emcn/icons' +import { Download, Link, Lock, Trash, Unlock } from '@sim/emcn/icons' import type { MoveOptionNode } from '@/app/workspace/[workspaceId]/files/move-options' import { renderMoveOption } from '@/app/workspace/[workspaceId]/files/move-options' @@ -29,9 +29,13 @@ interface FileRowContextMenuProps { onDelete: () => void onMove?: (optionValue: string) => void onShare?: () => void + onToggleLock?: () => void moveOptions?: MoveOptionNode[] canEdit: boolean selectedCount: number + showLock?: boolean + disableLock?: boolean + isLocked?: boolean } export const FileRowContextMenu = memo(function FileRowContextMenu({ @@ -44,9 +48,13 @@ export const FileRowContextMenu = memo(function FileRowContextMenu({ onDelete, onMove, onShare, + onToggleLock, moveOptions, canEdit, selectedCount, + showLock = false, + disableLock = false, + isLocked = false, }: FileRowContextMenuProps) { const isMultiSelect = selectedCount > 1 @@ -109,6 +117,12 @@ export const FileRowContextMenu = memo(function FileRowContextMenu({ )} + {showLock && onToggleLock && ( + + {isLocked ? : } + {isLocked ? 'Unlock' : 'Lock'} + + )} {isMultiSelect ? `Delete ${selectedCount} items` : 'Delete'} diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/use-markdown-mentions.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/use-markdown-mentions.ts index 023faacc43c..fbbdbf80dd6 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/use-markdown-mentions.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/use-markdown-mentions.ts @@ -1,10 +1,10 @@ import { useMemo } from 'react' import { listIntegrations } from '@/blocks/integration-matcher' +import { useFolders } from '@/hooks/queries/folders' import { useKnowledgeBasesQuery } from '@/hooks/queries/kb/knowledge' import { useSkills } from '@/hooks/queries/skills' import { useTablesList } from '@/hooks/queries/tables' import { useWorkflows } from '@/hooks/queries/workflows' -import { useWorkspaceFileFolders } from '@/hooks/queries/workspace-file-folders' import { useWorkspaceFiles } from '@/hooks/queries/workspace-files' import { mentionIcon } from './mention-icon' import type { MentionItem } from './types' @@ -27,7 +27,7 @@ export function useMarkdownMentions( const wsStr = ws ?? '' const files = useWorkspaceFiles(wsStr, 'active', { enabled: active }) - const folders = useWorkspaceFileFolders(wsStr, 'active') + const folders = useFolders(wsStr, { resourceType: 'file', scope: 'active' }) const tables = useTablesList(ws, 'active') const knowledgeBases = useKnowledgeBasesQuery(ws, { enabled: active }) const workflows = useWorkflows(ws) diff --git a/apps/sim/app/workspace/[workspaceId]/files/files.tsx b/apps/sim/app/workspace/[workspaceId]/files/files.tsx index 1d55ca37628..d4676e540eb 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/files.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/files.tsx @@ -7,6 +7,10 @@ import { ChipConfirmModal, Columns2, type ComboboxOption, + cellIconNodeClass, + chipContentGap, + chipContentLabelClass, + cn, Eye, File as FilesIcon, Folder, @@ -18,12 +22,13 @@ import { toast, Upload, } from '@sim/emcn' -import { Download, Send } from '@sim/emcn/icons' +import { Download, Lock, Send } from '@sim/emcn/icons' import { createLogger } from '@sim/logger' -import { getErrorMessage, toError } from '@sim/utils/errors' +import { getErrorMessage } from '@sim/utils/errors' import { useParams, useRouter } from 'next/navigation' import { useQueryStates } from 'nuqs' import { usePostHog } from 'posthog-js/react' +import { PinButton } from '@/components/folders/pin-button' import { getDocumentIcon } from '@/components/icons/document-icons' import { useLimitUpgradeToast } from '@/lib/billing/client' import { captureEvent } from '@/lib/posthog/client' @@ -57,6 +62,7 @@ import type { } from '@/app/workspace/[workspaceId]/components' import { EMPTY_CELL_PLACEHOLDER, + FloatingOverflowText, ownerCell, Resource, timeCell, @@ -78,14 +84,13 @@ import type { MoveOptionNode } from '@/app/workspace/[workspaceId]/files/move-op import { filesParsers, filesUrlKeys } from '@/app/workspace/[workspaceId]/files/search-params' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks' +import { useCreateFolder, useFolders, useUpdateFolder } from '@/hooks/queries/folders' +import { usePinnedIds } from '@/hooks/queries/pinned-items' +import { isFolderOrAncestorLocked } from '@/hooks/queries/utils/folder-tree' import { useWorkspaceMembersQuery, type WorkspaceMember } from '@/hooks/queries/workspace' import { useBulkArchiveWorkspaceFileItems, - useCreateWorkspaceFileFolder, useMoveWorkspaceFileItems, - useUpdateWorkspaceFileFolder, - useWorkspaceFileFolders, - type WorkspaceFileFolderApi, } from '@/hooks/queries/workspace-file-folders' import { useDeleteWorkspaceFile, @@ -94,13 +99,16 @@ import { useWorkspaceFiles, } from '@/hooks/queries/workspace-files' import { useDebounce } from '@/hooks/use-debounce' +import { useFolderBreadcrumbs } from '@/hooks/use-folder-breadcrumbs' +import { useFolderCreateWithDedup } from '@/hooks/use-folder-create-with-dedup' import { useInlineRename } from '@/hooks/use-inline-rename' import { usePermissionConfig } from '@/hooks/use-permission-config' +import type { Folder as FolderType } from '@/stores/folders/types' type SaveStatus = 'idle' | 'saving' | 'saved' | 'error' type FileResourceItem = | { kind: 'file'; id: string; file: WorkspaceFileRecord } - | { kind: 'folder'; id: string; folder: WorkspaceFileFolderApi } + | { kind: 'folder'; id: string; folder: FolderType } const logger = createLogger('Files') @@ -140,7 +148,7 @@ const MIME_TYPE_LABELS: Record = { } const EMPTY_WORKSPACE_FILES: WorkspaceFileRecord[] = [] -const EMPTY_WORKSPACE_FILE_FOLDERS: WorkspaceFileFolderApi[] = [] +const EMPTY_WORKSPACE_FILE_FOLDERS: FolderType[] = [] const fileRowId = (id: string) => `file:${id}` const folderRowId = (id: string) => `folder:${id}` @@ -178,6 +186,8 @@ export function Files() { const [{ folderId: currentFolderId, new: isNewFile, shareFileId }, setFilesParams] = useQueryStates(filesParsers, filesUrlKeys) const workspaceId = params?.workspaceId as string + const pinnedFolderIds = usePinnedIds(workspaceId, 'folder') + const pinnedFileIds = usePinnedIds(workspaceId, 'file') const posthog = usePostHog() const posthogRef = useRef(posthog) @@ -196,7 +206,9 @@ export function Files() { }, [permissionConfig.hideFilesTab, router, workspaceId]) const { data: files = EMPTY_WORKSPACE_FILES, isLoading, error } = useWorkspaceFiles(workspaceId) - const { data: folders = EMPTY_WORKSPACE_FILE_FOLDERS } = useWorkspaceFileFolders(workspaceId) + const { data: folders = EMPTY_WORKSPACE_FILE_FOLDERS } = useFolders(workspaceId, { + resourceType: 'file', + }) const { data: members } = useWorkspaceMembersQuery(workspaceId) const membersById = useMemo(() => { const map = new Map() @@ -207,8 +219,8 @@ export function Files() { const notifyLimit = useLimitUpgradeToast() const deleteFile = useDeleteWorkspaceFile() const renameFile = useRenameWorkspaceFile() - const createFolder = useCreateWorkspaceFileFolder() - const updateFolder = useUpdateWorkspaceFileFolder() + const createFolder = useCreateFolder() + const updateFolder = useUpdateFolder() const moveItems = useMoveWorkspaceFileItems() const bulkArchiveItems = useBulkArchiveWorkspaceFileItems() @@ -285,7 +297,12 @@ export function Files() { onSave: (rowId, name) => { const parsed = parseRowId(rowId) if (parsed.kind === 'folder') { - return updateFolder.mutateAsync({ workspaceId, folderId: parsed.id, updates: { name } }) + return updateFolder.mutateAsync({ + workspaceId, + resourceType: 'file', + id: parsed.id, + updates: { name }, + }) } return renameFile.mutateAsync({ workspaceId, fileId: parsed.id, name }) }, @@ -297,7 +314,12 @@ export function Files() { const breadcrumbRename = useInlineRename({ onSave: (folderId, name) => - updateFolder.mutateAsync({ workspaceId, folderId, updates: { name } }), + updateFolder.mutateAsync({ + workspaceId, + resourceType: 'file', + id: folderId, + updates: { name }, + }), }) const selectedFile = useMemo( @@ -322,7 +344,6 @@ export function Files() { ) : null const folderById = useMemo(() => new Map(folders.map((folder) => [folder.id, folder])), [folders]) - const currentFolder = currentFolderId ? (folderById.get(currentFolderId) ?? null) : null const folderSizeMap = useMemo(() => { const directSize = new Map() @@ -343,7 +364,6 @@ export function Files() { for (const folder of folders) getTotal(folder.id) return totalSize }, [files, folders]) - const currentFolderPath = currentFolder?.path ?? null const visibleFolders = useMemo(() => { const siblings = folders.filter((folder) => (folder.parentId ?? null) === currentFolderId) @@ -445,8 +465,32 @@ export function Files() { id: folderRowId(folder.id), cells: { name: { - icon: , - label: folder.name, + content: ( +
+ + + + + + + + {folder.locked && ( + + + )} + + +
+ ), }, size: { label: @@ -470,8 +514,32 @@ export function Files() { id: fileRowId(file.id), cells: { name: { - icon: , - label: file.name, + content: ( +
+ + + + + + + + {file.locked && ( + + + )} + + +
+ ), }, size: { label: formatFileSize(file.size, { includeBytes: true }), @@ -489,7 +557,15 @@ export function Files() { }) return [...folderRows, ...fileRows] - }, [visibleFolders, filteredFiles, membersById, folderSizeMap]) + }, [ + visibleFolders, + filteredFiles, + membersById, + folderSizeMap, + workspaceId, + pinnedFolderIds, + pinnedFileIds, + ]) const rows: ResourceRow[] = useMemo(() => { if (!listRename.editingId) return baseRows @@ -516,17 +592,27 @@ export function Files() { const visibleRowIds = useMemo(() => rows.map((row) => row.id), [rows]) - const prevVisibleRowIdsRef = useRef(visibleRowIds) - useEffect(() => { - if (prevVisibleRowIdsRef.current === visibleRowIds) return - prevVisibleRowIdsRef.current = visibleRowIds - lastSelectedIndexRef.current = -1 + // Prunes stale selections when the visible row set changes (folder nav, search, + // filter). Adjusted during render — not an Effect — per the "adjust state during + // render" pattern in .claude/rules/sim-hooks.md: this is state derived from a + // render-time value, not a subscription to an external system. + const [prevVisibleRowIds, setPrevVisibleRowIds] = useState(visibleRowIds) + if (prevVisibleRowIds !== visibleRowIds) { + setPrevVisibleRowIds(visibleRowIds) const visible = new Set(visibleRowIds) setSelectedRowIds((prev) => { if (prev.size === 0) return prev const next = new Set(Array.from(prev).filter((id) => visible.has(id))) return next.size === prev.size ? prev : next }) + } + + // lastSelectedIndexRef is only read inside event handlers, never during + // render/JSX, so it stays a ref — but writing ref.current during render is + // forbidden (react.dev, useRef → "Do not write or read ref.current during + // rendering"), so the reset runs in an Effect keyed on the same transition. + useEffect(() => { + lastSelectedIndexRef.current = -1 }, [visibleRowIds]) const isAllSelected = @@ -1172,32 +1258,21 @@ export function Files() { } }, [workspaceId, router, currentFolderId]) - const handleCreateFolder = useCallback(async () => { - if (!workspaceId) return - const existingNames = new Set( - folders - .filter((folder) => (folder.parentId ?? null) === currentFolderId) - .map((folder) => folder.name) - ) - let name = 'New folder' - let counter = 1 - while (existingNames.has(name)) { - name = `New folder (${counter})` - counter++ - } - - try { - const folder = await createFolder.mutateAsync({ - workspaceId, - name, - parentId: currentFolderId, - }) + const handleFolderCreated = useCallback( + (folder: FolderType) => { listRename.startRename(folderRowId(folder.id), folder.name) - } catch (error) { - logger.error('Failed to create folder:', error) - toast.error(toError(error).message) - } - }, [workspaceId, folders, currentFolderId, listRename.startRename]) + }, + [listRename.startRename] + ) + + const handleCreateFolder = useFolderCreateWithDedup({ + workspaceId, + resourceType: 'file', + folders, + currentFolderId, + createFolder, + onCreated: handleFolderCreated, + }) const handleRowContextMenu = useCallback( (e: React.MouseEvent, rowId: string) => { @@ -1209,7 +1284,7 @@ export function Files() { if (!item) return contextMenuItemRef.current = parsed.kind === 'folder' - ? { kind: 'folder', id: parsed.id, folder: item as WorkspaceFileFolderApi } + ? { kind: 'folder', id: parsed.id, folder: item as FolderType } : { kind: 'file', id: parsed.id, file: item as WorkspaceFileRecord } if (!selectedRowIds.has(rowId)) { lastSelectedIndexRef.current = visibleRowIds.indexOf(rowId) @@ -1286,6 +1361,42 @@ export function Files() { closeContextMenu() }, [selectedRowIds, handleBulkDelete, closeContextMenu]) + // Reads contextMenuItemRef.current directly at render time rather than through + // useMemo -- the ref updates on every right-click without necessarily toggling + // isContextMenuOpen (the menu can stay open across rows), so a memo keyed on + // isContextMenuOpen would return a stale value for the newly targeted row. The + // ancestor-chain walk is cheap enough to not need memoizing. + const contextMenuItem = contextMenuItemRef.current + const contextMenuInheritedLocked = contextMenuItem + ? isFolderOrAncestorLocked( + contextMenuItem.kind === 'file' + ? contextMenuItem.file.folderId + : contextMenuItem.folder.parentId, + Object.fromEntries(folderById) + ) + : false + + const handleContextMenuToggleLock = useCallback(() => { + const item = contextMenuItemRef.current + if (!item || contextMenuInheritedLocked) return + if (item.kind === 'file') { + renameFile.mutate({ + workspaceId, + fileId: item.file.id, + name: item.file.name, + locked: !item.file.locked, + }) + } else { + updateFolder.mutate({ + workspaceId, + resourceType: 'file', + id: item.folder.id, + updates: { locked: !item.folder.locked }, + }) + } + closeContextMenu() + }, [contextMenuInheritedLocked, renameFile, updateFolder, workspaceId, closeContextMenu]) + const handleContextMenuMove = useCallback( async (optionValue: string) => { const targetFolderId = optionValue === '__root__' ? null : optionValue @@ -1584,62 +1695,23 @@ export function Files() { [handleNavigateToFiles] ) - const breadcrumbRenameRef = useRef(breadcrumbRename) - breadcrumbRenameRef.current = breadcrumbRename - - const listBreadcrumbs = useMemo(() => { - const breadcrumbs: BreadcrumbItem[] = [{ label: 'Files', onClick: handleNavigateToFiles }] - if (!currentFolderPath) return breadcrumbs + const handleNavigateToFolder = useCallback( + (folderId: string) => { + void setFilesParams({ folderId, new: null }) + }, + [setFilesParams] + ) - const segments = currentFolderPath.split('/') - let parentId: string | null = null - for (let i = 0; i < segments.length; i++) { - const segment = segments[i] - const folder = folders.find( - (item) => item.name === segment && (item.parentId ?? null) === parentId - ) - if (!folder) continue - const isCurrentFolder = folder.id === currentFolderId - breadcrumbs.push({ - label: folder.name, - onClick: isCurrentFolder - ? undefined - : () => void setFilesParams({ folderId: folder.id, new: null }), - editing: - isCurrentFolder && breadcrumbRenameRef.current.editingId === folder.id - ? { - isEditing: true, - value: breadcrumbRenameRef.current.editValue, - onChange: breadcrumbRenameRef.current.setEditValue, - onSubmit: breadcrumbRenameRef.current.submitRename, - onCancel: breadcrumbRenameRef.current.cancelRename, - } - : undefined, - dropdownItems: - isCurrentFolder && (canEdit || userPermissions.isLoading) - ? [ - { - label: 'Rename', - disabled: !canEdit, - onClick: () => breadcrumbRenameRef.current.startRename(folder.id, folder.name), - }, - ] - : undefined, - }) - parentId = folder.id - } - return breadcrumbs - }, [ - currentFolderPath, + const listBreadcrumbs = useFolderBreadcrumbs({ + folderById, currentFolderId, - folders, - handleNavigateToFiles, - setFilesParams, + rootLabel: 'Files', + onNavigateRoot: handleNavigateToFiles, + onNavigateFolder: handleNavigateToFolder, + breadcrumbRename, canEdit, - userPermissions.isLoading, - breadcrumbRename.editingId, - breadcrumbRename.editValue, - ]) + canEditLoading: userPermissions.isLoading, + }) const memberOptions: ComboboxOption[] = useMemo( () => @@ -1991,6 +2063,14 @@ export function Files() { moveOptions={contextMenuMoveOptions} canEdit={canEdit} selectedCount={selectedRowIds.size} + onToggleLock={handleContextMenuToggleLock} + showLock={selectedRowIds.size <= 1} + disableLock={!userPermissions.canAdmin || contextMenuInheritedLocked} + isLocked={Boolean( + contextMenuItemRef.current?.kind === 'file' + ? contextMenuItemRef.current.file.locked + : contextMenuItemRef.current?.folder.locked + )} /> { - const data = await prefetchInternalJson<{ folders?: WorkspaceFileFolderApi[] }>( - `/api/workspaces/${workspaceId}/files/folders?scope=active` + const data = await prefetchInternalJson<{ folders?: FolderApi[] }>( + `/api/folders?workspaceId=${workspaceId}&resourceType=file&scope=active` ) - return data.folders ?? [] + return (data.folders ?? []).map(mapFolder) }, - staleTime: WORKSPACE_FILE_FOLDERS_STALE_TIME, + staleTime: FOLDER_LIST_STALE_TIME, }), ]) } diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/add-resource-dropdown.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/add-resource-dropdown.tsx index 7f67d25704f..4a1bfdd0c0b 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/add-resource-dropdown.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/add-resource-dropdown.tsx @@ -34,7 +34,6 @@ import { useMothershipChats } from '@/hooks/queries/mothership-chats' import { useWorkspaceSchedules } from '@/hooks/queries/schedules' import { useTablesList } from '@/hooks/queries/tables' import { useWorkflows } from '@/hooks/queries/workflows' -import { useWorkspaceFileFolders } from '@/hooks/queries/workspace-file-folders' import { useWorkspaceFiles } from '@/hooks/queries/workspace-files' export interface AddResourceDropdownProps { @@ -77,7 +76,7 @@ export function useAvailableResources( const { data: files = [] } = useWorkspaceFiles(workspaceId) const { data: knowledgeBases } = useKnowledgeBasesQuery(workspaceId) const { data: folders = [] } = useFolders(workspaceId) - const { data: fileFolders = [] } = useWorkspaceFileFolders(workspaceId) + const { data: fileFolders = [] } = useFolders(workspaceId, { resourceType: 'file' }) const { data: tasks = [] } = useMothershipChats(workspaceId) const { data: schedules = [] } = useWorkspaceSchedules(workspaceId) const { data: logsData } = useLogsList(workspaceId, LOG_DROPDOWN_FILTERS) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx index 0bfe6bdb233..e16ac642522 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx @@ -161,6 +161,7 @@ export const ResourceContent = memo(function ResourceContent({ size: 0, type, uploadedBy: '', + locked: false, uploadedAt: STREAMING_EPOCH, updatedAt: STREAMING_EPOCH, } diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/resource-registry.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/resource-registry.tsx index 5fbf767a4c0..42c6c83b5d1 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/resource-registry.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/resource-registry.tsx @@ -28,7 +28,6 @@ import { scheduleKeys } from '@/hooks/queries/schedules' import { folderKeys } from '@/hooks/queries/utils/folder-keys' import { invalidateWorkflowLists } from '@/hooks/queries/utils/invalidate-workflow-lists' import { tableKeys } from '@/hooks/queries/utils/table-keys' -import { workspaceFileFolderKeys } from '@/hooks/queries/workspace-file-folders' import { workspaceFilesKeys } from '@/hooks/queries/workspace-files' interface DropdownItemRenderProps { @@ -247,7 +246,7 @@ const RESOURCE_INVALIDATORS: Record< qc.invalidateQueries({ queryKey: folderKeys.lists() }) }, filefolder: (qc, wId) => { - qc.invalidateQueries({ queryKey: workspaceFileFolderKeys.workspaceLists(wId) }) + qc.invalidateQueries({ queryKey: folderKeys.workspaceResourceLists(wId, 'file') }) }, task: (qc, wId) => { qc.invalidateQueries({ queryKey: mothershipChatKeys.list(wId) }) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor.test.tsx index 4a8696a0f15..63e42d61a30 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor.test.tsx @@ -11,9 +11,6 @@ vi.mock('@/hooks/queries/tables', () => ({ useTablesList: () => ({ data: [] }) } vi.mock('@/hooks/queries/workspace-files', () => ({ useWorkspaceFiles: () => ({ data: [] }) })) vi.mock('@/hooks/queries/kb/knowledge', () => ({ useKnowledgeBasesQuery: () => ({ data: [] }) })) vi.mock('@/hooks/queries/folders', () => ({ useFolders: () => ({ data: [] }) })) -vi.mock('@/hooks/queries/workspace-file-folders', () => ({ - useWorkspaceFileFolders: () => ({ data: [] }), -})) vi.mock('@/hooks/queries/mothership-chats', () => ({ useMothershipChats: () => ({ data: [] }) })) vi.mock('@/hooks/queries/schedules', () => ({ useWorkspaceSchedules: () => ({ data: [] }) })) vi.mock('@/hooks/queries/logs', () => ({ useLogsList: () => ({ data: undefined }) })) diff --git a/apps/sim/app/workspace/[workspaceId]/home/prefetch.ts b/apps/sim/app/workspace/[workspaceId]/home/prefetch.ts index adf7a42630b..e1ab261fc78 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/prefetch.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/prefetch.ts @@ -28,10 +28,10 @@ export async function prefetchHomeLists( ): Promise { await Promise.all([ queryClient.prefetchQuery({ - queryKey: folderKeys.list(workspaceId, 'active'), + queryKey: folderKeys.list(workspaceId, 'workflow', 'active'), queryFn: async () => { const { folders } = await prefetchInternalJson<{ folders?: FolderApi[] }>( - `/api/folders?workspaceId=${workspaceId}&scope=active` + `/api/folders?workspaceId=${workspaceId}&resourceType=workflow&scope=active` ) return (folders ?? []).map(mapFolder) }, diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/components/create-base-modal/create-base-modal.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/components/create-base-modal/create-base-modal.tsx index 25b37a670b9..59bacf013d0 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/components/create-base-modal/create-base-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/components/create-base-modal/create-base-modal.tsx @@ -37,6 +37,8 @@ const logger = createLogger('CreateBaseModal') interface CreateBaseModalProps { open: boolean onOpenChange: (open: boolean) => void + /** Folder the new base is created into; omit/null for the workspace root. */ + folderId?: string | null } const STRATEGY_OPTIONS = [ @@ -127,6 +129,7 @@ interface SubmitStatus { export const CreateBaseModal = memo(function CreateBaseModal({ open, onOpenChange, + folderId, }: CreateBaseModalProps) { const params = useParams() const workspaceId = params.workspaceId as string @@ -262,6 +265,7 @@ export const CreateBaseModal = memo(function CreateBaseModal({ name: data.name, description: data.description || undefined, workspaceId: workspaceId, + folderId: folderId ?? undefined, chunkingConfig: { maxSize: data.maxChunkSize, minSize: data.minChunkSize, diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/components/delete-knowledge-base-modal/delete-knowledge-base-modal.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/components/delete-knowledge-base-modal/delete-knowledge-base-modal.tsx index 5d99ed22be3..157d1d7b647 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/components/delete-knowledge-base-modal/delete-knowledge-base-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/components/delete-knowledge-base-modal/delete-knowledge-base-modal.tsx @@ -21,14 +21,19 @@ interface DeleteKnowledgeBaseModalProps { */ isDeleting: boolean /** - * Name of the knowledge base being deleted + * Name of the knowledge base (or folder) being deleted */ knowledgeBaseName?: string + /** + * 'base' (default) deletes a single knowledge base; 'folder' deletes a + * knowledge base folder, which cascades to the bases inside it. + */ + kind?: 'base' | 'folder' } /** - * Delete confirmation modal for knowledge base items. - * Displays a warning message and confirmation buttons. + * Delete confirmation modal for knowledge base items and knowledge base + * folders. Displays a warning message and confirmation buttons. */ export const DeleteKnowledgeBaseModal = memo(function DeleteKnowledgeBaseModal({ isOpen, @@ -36,31 +41,33 @@ export const DeleteKnowledgeBaseModal = memo(function DeleteKnowledgeBaseModal({ onConfirm, isDeleting, knowledgeBaseName, + kind = 'base', }: DeleteKnowledgeBaseModalProps) { + const isFolder = kind === 'folder' + const title = isFolder ? 'Delete Folder' : 'Delete Knowledge Base' + const consequence = isFolder + ? 'All bases (and their documents, chunks, and embeddings) inside it will be removed.' + : 'All associated documents, chunks, and embeddings will be removed.' + const subject = isFolder ? 'this folder' : 'this knowledge base' + return ( void onEdit?: () => void onDelete?: () => void + onToggleLock?: () => void showOpenInNewTab?: boolean showViewTags?: boolean showEdit?: boolean showDelete?: boolean + showLock?: boolean disableEdit?: boolean disableDelete?: boolean + disableLock?: boolean + isLocked?: boolean } /** @@ -40,16 +52,20 @@ export const KnowledgeBaseContextMenu = memo(function KnowledgeBaseContextMenu({ onCopyId, onEdit, onDelete, + onToggleLock, showOpenInNewTab = true, showViewTags = true, showEdit = true, showDelete = true, + showLock = false, disableEdit = false, disableDelete = false, + disableLock = false, + isLocked = false, }: KnowledgeBaseContextMenuProps) { const hasNavigationSection = showOpenInNewTab && !!onOpenInNewTab const hasInfoSection = (showViewTags && !!onViewTags) || !!onCopyId - const hasEditSection = showEdit && !!onEdit + const hasEditSection = (showEdit && !!onEdit) || (showLock && !!onToggleLock) const hasDestructiveSection = showDelete && !!onDelete return ( @@ -104,6 +120,12 @@ export const KnowledgeBaseContextMenu = memo(function KnowledgeBaseContextMenu({ Edit
)} + {showLock && onToggleLock && ( + + {isLocked ? : } + {isLocked ? 'Unlock' : 'Lock'} + + )} {hasEditSection && hasDestructiveSection && } {showDelete && onDelete && ( diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/components/knowledge-folder-context-menu/index.ts b/apps/sim/app/workspace/[workspaceId]/knowledge/components/knowledge-folder-context-menu/index.ts new file mode 100644 index 00000000000..fa5271c95e3 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/components/knowledge-folder-context-menu/index.ts @@ -0,0 +1 @@ +export { KnowledgeFolderContextMenu } from './knowledge-folder-context-menu' diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/components/knowledge-folder-context-menu/knowledge-folder-context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/components/knowledge-folder-context-menu/knowledge-folder-context-menu.tsx new file mode 100644 index 00000000000..4e215c899e5 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/components/knowledge-folder-context-menu/knowledge-folder-context-menu.tsx @@ -0,0 +1,91 @@ +'use client' + +import { memo } from 'react' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@sim/emcn' +import { Eye, Lock, Pencil, Trash, Unlock } from '@sim/emcn/icons' + +interface KnowledgeFolderContextMenuProps { + isOpen: boolean + position: { x: number; y: number } + onClose: () => void + onOpen?: () => void + onRename?: () => void + onDelete?: () => void + onToggleLock?: () => void + canEdit: boolean + showLock?: boolean + disableLock?: boolean + isLocked?: boolean +} + +/** + * Context menu for knowledge base folder rows: open, rename, lock, delete. + */ +export const KnowledgeFolderContextMenu = memo(function KnowledgeFolderContextMenu({ + isOpen, + position, + onClose, + onOpen, + onRename, + onDelete, + onToggleLock, + canEdit, + showLock = false, + disableLock = false, + isLocked = false, +}: KnowledgeFolderContextMenuProps) { + return ( + !open && onClose()} modal={false}> + +
+ + e.preventDefault()} + > + {onOpen && ( + + + Open + + )} + {canEdit && ( + <> + + {onRename && ( + + + Rename + + )} + {showLock && onToggleLock && ( + + {isLocked ? : } + {isLocked ? 'Unlock' : 'Lock'} + + )} + {onDelete && ( + + + Delete + + )} + + )} + + + ) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/components/knowledge-list-context-menu/knowledge-list-context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/components/knowledge-list-context-menu/knowledge-list-context-menu.tsx index 9c5037bfade..eb12fd44265 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/components/knowledge-list-context-menu/knowledge-list-context-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/components/knowledge-list-context-menu/knowledge-list-context-menu.tsx @@ -2,14 +2,16 @@ import { memo } from 'react' import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@sim/emcn' -import { Plus } from '@sim/emcn/icons' +import { FolderPlus, Plus } from '@sim/emcn/icons' interface KnowledgeListContextMenuProps { isOpen: boolean position: { x: number; y: number } onClose: () => void onAddKnowledgeBase?: () => void + onCreateFolder?: () => void disableAdd?: boolean + disableCreateFolder?: boolean } /** @@ -21,7 +23,9 @@ export const KnowledgeListContextMenu = memo(function KnowledgeListContextMenu({ position, onClose, onAddKnowledgeBase, + onCreateFolder, disableAdd = false, + disableCreateFolder = false, }: KnowledgeListContextMenuProps) { return ( !open && onClose()} modal={false}> @@ -51,6 +55,12 @@ export const KnowledgeListContextMenu = memo(function KnowledgeListContextMenu({ Add knowledge base )} + {onCreateFolder && ( + + + New folder + + )} ) diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx index be7b5ccd801..7e0db809b66 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx @@ -2,10 +2,23 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import type { ChipDropdownOption } from '@sim/emcn' -import { Button, ChipDropdown, Plus, Tooltip } from '@sim/emcn' -import { Database } from '@sim/emcn/icons' +import { + Button, + ChipDropdown, + cellIconNodeClass, + chipContentGap, + chipContentLabelClass, + cn, + Folder, + FolderPlus, + Plus, + Tooltip, +} from '@sim/emcn' +import { Database, Lock } from '@sim/emcn/icons' import { createLogger } from '@sim/logger' import { useParams, useRouter } from 'next/navigation' +import { useQueryStates } from 'nuqs' +import { PinButton } from '@/components/folders/pin-button' import type { KnowledgeBaseData } from '@/lib/knowledge/types' import type { FilterTag, @@ -18,6 +31,7 @@ import type { } from '@/app/workspace/[workspaceId]/components' import { EMPTY_CELL_PLACEHOLDER, + FloatingOverflowText, ownerCell, Resource, timeCell, @@ -28,17 +42,34 @@ import { DeleteKnowledgeBaseModal, EditKnowledgeBaseModal, KnowledgeBaseContextMenu, + KnowledgeFolderContextMenu, KnowledgeListContextMenu, } from '@/app/workspace/[workspaceId]/knowledge/components' +import { + knowledgeFolderParsers, + knowledgeFolderUrlKeys, +} from '@/app/workspace/[workspaceId]/knowledge/search-params' import { filterKnowledgeBases } from '@/app/workspace/[workspaceId]/knowledge/utils/sort' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks' import { CONNECTOR_META_REGISTRY } from '@/connectors/registry' import { useKnowledgeBasesList } from '@/hooks/kb/use-knowledge' +import { + useCreateFolder, + useDeleteFolderMutation, + useFolders, + useUpdateFolder, +} from '@/hooks/queries/folders' import { useDeleteKnowledgeBase, useUpdateKnowledgeBase } from '@/hooks/queries/kb/knowledge' +import { usePinnedIds } from '@/hooks/queries/pinned-items' +import { isFolderOrAncestorLocked } from '@/hooks/queries/utils/folder-tree' import { useWorkspaceMembersQuery } from '@/hooks/queries/workspace' import { useDebounce } from '@/hooks/use-debounce' +import { useFolderBreadcrumbs } from '@/hooks/use-folder-breadcrumbs' +import { useFolderCreateWithDedup } from '@/hooks/use-folder-create-with-dedup' +import { useInlineRename } from '@/hooks/use-inline-rename' import { usePermissionConfig } from '@/hooks/use-permission-config' +import type { Folder as FolderType } from '@/stores/folders/types' const logger = createLogger('Knowledge') @@ -57,6 +88,7 @@ const COLUMNS: ResourceColumn[] = [ ] const KNOWLEDGE_BASE_ICON = +const FOLDER_ICON = const CONNECTOR_FILTER_OPTIONS: ChipDropdownOption[] = [ { value: 'all', label: 'All' }, @@ -72,6 +104,13 @@ const CONTENT_FILTER_OPTIONS: ChipDropdownOption[] = [ const FILTER_SECTION_LABEL_CLASS = 'text-[var(--text-muted)] text-small' +/** Folder rows are prefixed so their ids never collide with a (bare) knowledge base id. */ +const folderRowId = (id: string) => `folder:${id}` +const parseRowId = (rowId: string): { kind: 'folder' | 'base'; id: string } => { + if (rowId.startsWith('folder:')) return { kind: 'folder', id: rowId.slice('folder:'.length) } + return { kind: 'base', id: rowId } +} + function connectorCell(connectorTypes?: string[]): ResourceCell { if (!connectorTypes || connectorTypes.length === 0) { return { label: EMPTY_CELL_PLACEHOLDER } @@ -125,6 +164,11 @@ export function Knowledge() { const router = useRouter() const workspaceId = params.workspaceId as string + const [{ folderId: currentFolderId }, setKnowledgeFolderParams] = useQueryStates( + knowledgeFolderParsers, + knowledgeFolderUrlKeys + ) + const { config: permissionConfig } = usePermissionConfig() useEffect(() => { if (permissionConfig.hideKnowledgeBaseTab) { @@ -133,15 +177,23 @@ export function Knowledge() { }, [permissionConfig.hideKnowledgeBaseTab, router, workspaceId]) const { knowledgeBases, error } = useKnowledgeBasesList(workspaceId) + const { data: folders = [] } = useFolders(workspaceId, { resourceType: 'knowledge_base' }) const { data: members } = useWorkspaceMembersQuery(workspaceId) + const pinnedBaseIds = usePinnedIds(workspaceId, 'knowledge_base') + const pinnedFolderIds = usePinnedIds(workspaceId, 'folder') + if (error) { logger.error('Failed to load knowledge bases:', error) } const userPermissions = useUserPermissionsContext() + const canEdit = userPermissions.canEdit === true const { mutateAsync: updateKnowledgeBaseMutation } = useUpdateKnowledgeBase(workspaceId) const { mutateAsync: deleteKnowledgeBaseMutation } = useDeleteKnowledgeBase(workspaceId) + const createFolder = useCreateFolder() + const updateFolder = useUpdateFolder() + const deleteFolder = useDeleteFolderMutation() const [activeSort, setActiveSort] = useState<{ column: string @@ -164,6 +216,9 @@ export function Knowledge() { const [isTagsModalOpen, setIsTagsModalOpen] = useState(false) const [isDeleting, setIsDeleting] = useState(false) + const [activeFolderId, setActiveFolderId] = useState(null) + const [isFolderDeleteModalOpen, setIsFolderDeleteModalOpen] = useState(false) + const { isOpen: isListContextMenuOpen, position: listContextMenuPosition, @@ -178,8 +233,17 @@ export function Knowledge() { closeMenu: closeRowContextMenu, } = useContextMenu() + const { + isOpen: isFolderContextMenuOpen, + position: folderContextMenuPosition, + handleContextMenu: handleFolderCtxMenu, + closeMenu: closeFolderContextMenu, + } = useContextMenu() + const isRowContextMenuOpenRef = useRef(isRowContextMenuOpen) isRowContextMenuOpenRef.current = isRowContextMenuOpen + const isFolderContextMenuOpenRef = useRef(isFolderContextMenuOpen) + isFolderContextMenuOpenRef.current = isFolderContextMenuOpen const knowledgeBasesRef = useRef(knowledgeBases) knowledgeBasesRef.current = knowledgeBases @@ -187,6 +251,31 @@ export function Knowledge() { const activeKnowledgeBaseRef = useRef(activeKnowledgeBase) activeKnowledgeBaseRef.current = activeKnowledgeBase + const listRename = useInlineRename({ + onSave: (rowId, name) => { + const parsed = parseRowId(rowId) + if (parsed.kind === 'folder') { + return updateFolder.mutateAsync({ + workspaceId, + resourceType: 'knowledge_base', + id: parsed.id, + updates: { name }, + }) + } + return updateKnowledgeBaseMutation({ knowledgeBaseId: parsed.id, updates: { name } }) + }, + }) + + const breadcrumbRename = useInlineRename({ + onSave: (folderId, name) => + updateFolder.mutateAsync({ + workspaceId, + resourceType: 'knowledge_base', + id: folderId, + updates: { name }, + }), + }) + const handleContentContextMenu = useCallback( (e: React.MouseEvent) => { const target = e.target as HTMLElement @@ -224,8 +313,37 @@ export function Knowledge() { [deleteKnowledgeBaseMutation] ) + const folderById = useMemo(() => new Map(folders.map((folder) => [folder.id, folder])), [folders]) + const folderByIdRecord = useMemo(() => Object.fromEntries(folderById), [folderById]) + + const activeFolder = activeFolderId ? (folderById.get(activeFolderId) ?? null) : null + + const visibleFolders = useMemo(() => { + const siblings = folders.filter((folder) => (folder.parentId ?? null) === currentFolderId) + const searched = debouncedSearchQuery + ? siblings.filter((folder) => + folder.name.toLowerCase().includes(debouncedSearchQuery.toLowerCase()) + ) + : siblings + const col = activeSort?.column ?? 'name' + const dir = activeSort?.direction ?? 'asc' + return [...searched].sort((a, b) => { + let cmp = 0 + if (col === 'updated') { + cmp = new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime() + } else if (col === 'created') { + cmp = new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime() + } else { + cmp = a.name.localeCompare(b.name) + } + return dir === 'asc' ? cmp : -cmp + }) + }, [folders, currentFolderId, debouncedSearchQuery, activeSort]) + const processedKBs = useMemo(() => { - let result = filterKnowledgeBases(knowledgeBases, debouncedSearchQuery) + let result = filterKnowledgeBases(knowledgeBases, debouncedSearchQuery).filter( + (kb) => (kb.folderId ?? null) === currentFolderId + ) if (connectorFilter.length > 0) { result = result.filter((kb) => { @@ -284,6 +402,7 @@ export function Knowledge() { }) }, [ knowledgeBases, + currentFolderId, debouncedSearchQuery, connectorFilter, contentFilter, @@ -292,7 +411,7 @@ export function Knowledge() { members, ]) - const rows: ResourceRow[] = useMemo( + const baseRows: ResourceRow[] = useMemo( () => processedKBs.map((kb) => { const kbWithCount = kb as KnowledgeBaseWithDocCount @@ -300,8 +419,30 @@ export function Knowledge() { id: kb.id, cells: { name: { - icon: KNOWLEDGE_BASE_ICON, - label: kb.name, + content: ( + + + {KNOWLEDGE_BASE_ICON} + + + + {kb.locked && ( + + + )} + + + + ), }, documents: { label: String(kbWithCount.docCount || 0), @@ -316,29 +457,107 @@ export function Knowledge() { }, } }), - [processedKBs, members] + [processedKBs, members, workspaceId, pinnedBaseIds] ) + const folderRows: ResourceRow[] = useMemo( + () => + visibleFolders.map((folder) => ({ + id: folderRowId(folder.id), + cells: { + name: { + content: ( + + + {FOLDER_ICON} + + + + {folder.locked && ( + + + )} + + + + ), + }, + documents: { label: EMPTY_CELL_PLACEHOLDER }, + tokens: { label: EMPTY_CELL_PLACEHOLDER }, + connectors: { label: EMPTY_CELL_PLACEHOLDER }, + created: timeCell(folder.createdAt), + owner: ownerCell(folder.userId, members), + updated: timeCell(folder.updatedAt), + }, + })), + [visibleFolders, members, workspaceId, pinnedFolderIds] + ) + + const rows: ResourceRow[] = useMemo(() => { + const baseRowsList = [...folderRows, ...baseRows] + if (!listRename.editingId) return baseRowsList + return baseRowsList.map((row) => { + if (row.id !== listRename.editingId) return row + return { + ...row, + cells: { + ...row.cells, + name: { + ...row.cells.name, + editing: { + value: listRename.editValue, + onChange: listRename.setEditValue, + onSubmit: listRename.submitRename, + onCancel: listRename.cancelRename, + disabled: listRename.isSaving, + }, + }, + }, + } + }) + }, [folderRows, baseRows, listRename.editingId, listRename.editValue, listRename.isSaving]) + const handleRowClick = useCallback( (rowId: string) => { - if (isRowContextMenuOpenRef.current) return - const kb = knowledgeBasesRef.current.find((k) => k.id === rowId) + if (isRowContextMenuOpenRef.current || isFolderContextMenuOpenRef.current) return + if (listRename.editingId === rowId) return + const parsed = parseRowId(rowId) + if (parsed.kind === 'folder') { + void setKnowledgeFolderParams({ folderId: parsed.id }) + return + } + const kb = knowledgeBasesRef.current.find((k) => k.id === parsed.id) if (!kb) return const urlParams = new URLSearchParams({ kbName: kb.name }) - router.push(`/workspace/${workspaceId}/knowledge/${rowId}?${urlParams.toString()}`) + router.push(`/workspace/${workspaceId}/knowledge/${kb.id}?${urlParams.toString()}`) }, - [router, workspaceId] + [router, workspaceId, setKnowledgeFolderParams, listRename.editingId] ) const handleRowContextMenu = useCallback( (e: React.MouseEvent, rowId: string) => { - const kb = knowledgeBasesRef.current.find((k) => k.id === rowId) as + const parsed = parseRowId(rowId) + if (parsed.kind === 'folder') { + setActiveFolderId(parsed.id) + handleFolderCtxMenu(e) + return + } + const kb = knowledgeBasesRef.current.find((k) => k.id === parsed.id) as | KnowledgeBaseWithDocCount | undefined setActiveKnowledgeBase(kb ?? null) handleRowCtxMenu(e) }, - [handleRowCtxMenu] + [handleRowCtxMenu, handleFolderCtxMenu] ) const handleConfirmDelete = useCallback(async () => { @@ -385,10 +604,99 @@ export function Knowledge() { setIsDeleteModalOpen(true) }, []) - const canEdit = userPermissions.canEdit === true + const knowledgeBaseInheritedLocked = isFolderOrAncestorLocked( + activeKnowledgeBase?.folderId ?? null, + folderByIdRecord + ) + + const handleToggleKnowledgeBaseLock = useCallback(() => { + const kb = activeKnowledgeBaseRef.current + if (!kb || knowledgeBaseInheritedLocked) return + updateKnowledgeBaseMutation({ + knowledgeBaseId: kb.id, + updates: { locked: !kb.locked }, + }) + }, [updateKnowledgeBaseMutation, knowledgeBaseInheritedLocked]) + + const handleFolderOpen = useCallback(() => { + if (!activeFolderId) return + void setKnowledgeFolderParams({ folderId: activeFolderId }) + closeFolderContextMenu() + }, [activeFolderId, setKnowledgeFolderParams, closeFolderContextMenu]) + + const handleFolderRename = useCallback(() => { + if (!activeFolder) return + listRename.startRename(folderRowId(activeFolder.id), activeFolder.name) + closeFolderContextMenu() + }, [activeFolder, listRename.startRename, closeFolderContextMenu]) + + const handleFolderDelete = useCallback(() => { + setIsFolderDeleteModalOpen(true) + closeFolderContextMenu() + }, [closeFolderContextMenu]) + + const folderInheritedLocked = isFolderOrAncestorLocked( + activeFolder?.parentId ?? null, + folderByIdRecord + ) + + const handleToggleFolderLock = useCallback(() => { + if (!activeFolder || folderInheritedLocked) return + updateFolder.mutate({ + workspaceId, + resourceType: 'knowledge_base', + id: activeFolder.id, + updates: { locked: !activeFolder.locked }, + }) + }, [activeFolder, folderInheritedLocked, updateFolder, workspaceId]) + + const handleCloseFolderDeleteModal = useCallback(() => { + setIsFolderDeleteModalOpen(false) + setActiveFolderId(null) + }, []) + + const handleConfirmFolderDelete = useCallback(async () => { + if (!activeFolderId) return + try { + await deleteFolder.mutateAsync({ + workspaceId, + resourceType: 'knowledge_base', + id: activeFolderId, + }) + setIsFolderDeleteModalOpen(false) + setActiveFolderId(null) + if (currentFolderId === activeFolderId) { + void setKnowledgeFolderParams({ folderId: null }) + } + } catch (deleteError) { + logger.error('Failed to delete folder:', deleteError) + } + }, [activeFolderId, deleteFolder, workspaceId, currentFolderId, setKnowledgeFolderParams]) + + const handleFolderCreated = useCallback( + (folder: FolderType) => { + listRename.startRename(folderRowId(folder.id), folder.name) + }, + [listRename.startRename] + ) + + const handleCreateFolder = useFolderCreateWithDedup({ + workspaceId, + resourceType: 'knowledge_base', + folders, + currentFolderId, + createFolder, + onCreated: handleFolderCreated, + }) const headerActions: ResourceAction[] = useMemo( () => [ + { + text: 'New folder', + icon: FolderPlus, + onSelect: handleCreateFolder, + disabled: createFolder.isPending || !canEdit, + }, { text: 'New base', icon: Plus, @@ -397,7 +705,7 @@ export function Knowledge() { variant: 'primary', }, ], - [handleOpenCreateModal, canEdit] + [handleCreateFolder, createFolder.isPending, handleOpenCreateModal, canEdit] ) const searchConfig: SearchConfig = useMemo( @@ -555,10 +863,37 @@ export function Knowledge() { return tags }, [connectorFilter, contentFilter, ownerFilter, members]) + const handleNavigateToRoot = useCallback(() => { + void setKnowledgeFolderParams({ folderId: null }) + }, [setKnowledgeFolderParams]) + + const handleNavigateToFolder = useCallback( + (folderId: string) => { + void setKnowledgeFolderParams({ folderId }) + }, + [setKnowledgeFolderParams] + ) + + const listBreadcrumbs = useFolderBreadcrumbs({ + folderById, + currentFolderId, + rootLabel: 'Knowledge Base', + onNavigateRoot: handleNavigateToRoot, + onNavigateFolder: handleNavigateToFolder, + breadcrumbRename, + canEdit, + canEditLoading: userPermissions.isLoading, + }) + return ( <> - + {activeKnowledgeBase && ( @@ -597,6 +934,26 @@ export function Knowledge() { showDelete disableEdit={!canEdit} disableDelete={!canEdit} + onToggleLock={handleToggleKnowledgeBaseLock} + showLock + disableLock={!userPermissions.canAdmin || knowledgeBaseInheritedLocked} + isLocked={Boolean(activeKnowledgeBase.locked)} + /> + )} + + {activeFolder && ( + )} @@ -622,6 +979,17 @@ export function Knowledge() { /> )} + {activeFolder && ( + + )} + {activeKnowledgeBase && ( )} - + ) } diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/page.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/page.tsx index 6243dc42035..59788aede67 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/page.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/page.tsx @@ -1,6 +1,8 @@ +import { Suspense } from 'react' import { dehydrate, HydrationBoundary } from '@tanstack/react-query' import type { Metadata } from 'next' import { getQueryClient } from '@/app/_shell/providers/get-query-client' +import KnowledgeLoading from '@/app/workspace/[workspaceId]/knowledge/loading' import { prefetchKnowledgeBases } from '@/app/workspace/[workspaceId]/knowledge/prefetch' import { Knowledge } from './knowledge' @@ -8,6 +10,13 @@ export const metadata: Metadata = { title: 'Knowledge Base', } +/** + * Knowledge page entry. `Knowledge` reads the current folder via nuqs (which + * uses `useSearchParams` internally), so it must sit under a Suspense + * boundary. The fallback renders the real chrome so a suspend never shows a + * blank frame; the route-level `loading.tsx` covers the navigation/chunk-load + * transition the same way. + */ export default async function KnowledgePage({ params, }: { @@ -20,7 +29,9 @@ export default async function KnowledgePage({ return ( - + }> + + ) } diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/search-params.ts b/apps/sim/app/workspace/[workspaceId]/knowledge/search-params.ts new file mode 100644 index 00000000000..c4b7cdd482c --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/search-params.ts @@ -0,0 +1,19 @@ +import { parseAsString } from 'nuqs/server' + +/** + * Co-located, typed URL query-param definition for the Knowledge Base list's + * folder navigation. `folderId` is the currently open folder — shareable, + * bookmarkable, and each navigation between folders is a destination, so it + * belongs in browser history (`history: 'push'`). This is a separate, + * single-key group from the page's other filters (search/sort/connector/ + * content/owner), which are local `useState` and intentionally do not touch + * the URL. + */ +export const knowledgeFolderParsers = { + folderId: parseAsString, +} as const + +export const knowledgeFolderUrlKeys = { + history: 'push', + clearOnDefault: true, +} as const diff --git a/apps/sim/app/workspace/[workspaceId]/lib/prefetch.test.ts b/apps/sim/app/workspace/[workspaceId]/lib/prefetch.test.ts index 4784cf778e9..b4d5c6139ea 100644 --- a/apps/sim/app/workspace/[workspaceId]/lib/prefetch.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/lib/prefetch.test.ts @@ -23,7 +23,6 @@ import { prefetchTables } from '@/app/workspace/[workspaceId]/tables/prefetch' import { knowledgeKeys } from '@/hooks/queries/kb/knowledge' import { folderKeys } from '@/hooks/queries/utils/folder-keys' import { tableKeys } from '@/hooks/queries/utils/table-keys' -import { workspaceFileFolderKeys } from '@/hooks/queries/workspace-file-folders' import { workspaceFilesKeys } from '@/hooks/queries/workspace-files' const WORKSPACE_ID = 'ws-123' @@ -70,9 +69,20 @@ describe('workspace list prefetches', () => { describe('prefetchFilesBrowser', () => { it('primes both file + folder keys the client hooks read', async () => { const files = [{ id: 'f-1' }] - const folders = [{ id: 'folder-1' }] + const folderRow = { + id: 'folder-1', + name: 'Docs', + userId: 'u-1', + workspaceId: WORKSPACE_ID, + parentId: null, + locked: false, + sortOrder: 0, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-02T00:00:00.000Z', + deletedAt: null, + } mockPrefetchInternalJson.mockImplementation(async (path: string) => - path.includes('/folders') ? { folders } : { success: true, files } + path.startsWith('/api/folders') ? { folders: [folderRow] } : { success: true, files } ) const client = makeClient() @@ -82,17 +92,19 @@ describe('workspace list prefetches', () => { `/api/workspaces/${WORKSPACE_ID}/files?scope=active` ) expect(mockPrefetchInternalJson).toHaveBeenCalledWith( - `/api/workspaces/${WORKSPACE_ID}/files/folders?scope=active` + `/api/folders?workspaceId=${WORKSPACE_ID}&resourceType=file&scope=active` ) expect(client.getQueryData(workspaceFilesKeys.list(WORKSPACE_ID, 'active'))).toEqual(files) - expect(client.getQueryData(workspaceFileFolderKeys.list(WORKSPACE_ID, 'active'))).toEqual( - folders - ) + const cachedFolders = client.getQueryData( + folderKeys.list(WORKSPACE_ID, 'file', 'active') + ) as Array<{ id: string; createdAt: Date }> + expect(cachedFolders).toHaveLength(1) + expect(cachedFolders[0].createdAt).toBeInstanceOf(Date) }) it('caches an empty file list when the route reports failure', async () => { mockPrefetchInternalJson.mockImplementation(async (path: string) => - path.includes('/folders') ? { folders: [] } : { success: false, files: [] } + path.startsWith('/api/folders') ? { folders: [] } : { success: false, files: [] } ) const client = makeClient() @@ -110,8 +122,6 @@ describe('workspace list prefetches', () => { userId: 'u-1', workspaceId: WORKSPACE_ID, parentId: null, - color: null, - isExpanded: true, locked: false, sortOrder: 0, createdAt: '2026-01-01T00:00:00.000Z', @@ -127,15 +137,15 @@ describe('workspace list prefetches', () => { await prefetchHomeLists(client, WORKSPACE_ID) expect(mockPrefetchInternalJson).toHaveBeenCalledWith( - `/api/folders?workspaceId=${WORKSPACE_ID}&scope=active` + `/api/folders?workspaceId=${WORKSPACE_ID}&resourceType=workflow&scope=active` ) - const cachedFolders = client.getQueryData(folderKeys.list(WORKSPACE_ID, 'active')) as Array<{ + const cachedFolders = client.getQueryData( + folderKeys.list(WORKSPACE_ID, 'workflow', 'active') + ) as Array<{ id: string - color: string createdAt: Date }> expect(cachedFolders).toHaveLength(1) - expect(cachedFolders[0].color).toBe('#6B7280') expect(cachedFolders[0].createdAt).toBeInstanceOf(Date) expect(client.getQueryData(workspaceFilesKeys.list(WORKSPACE_ID, 'active'))).toEqual(files) }) @@ -149,7 +159,7 @@ describe('workspace list prefetches', () => { knowledgeKeys.list(WORKSPACE_ID, 'active'), ], ['prefetchTables', prefetchTables, tableKeys.list(WORKSPACE_ID, 'active')], - ['prefetchHomeLists', prefetchHomeLists, folderKeys.list(WORKSPACE_ID, 'active')], + ['prefetchHomeLists', prefetchHomeLists, folderKeys.list(WORKSPACE_ID, 'workflow', 'active')], [ 'prefetchFilesBrowser', prefetchFilesBrowser, diff --git a/apps/sim/app/workspace/[workspaceId]/prefetch.ts b/apps/sim/app/workspace/[workspaceId]/prefetch.ts index 729d2dc0ad8..1708504f5e8 100644 --- a/apps/sim/app/workspace/[workspaceId]/prefetch.ts +++ b/apps/sim/app/workspace/[workspaceId]/prefetch.ts @@ -62,9 +62,9 @@ export async function prefetchWorkspaceSidebar( staleTime: MOTHERSHIP_CHAT_LIST_STALE_TIME, }), queryClient.prefetchQuery({ - queryKey: folderKeys.list(workspaceId, 'active'), + queryKey: folderKeys.list(workspaceId, 'workflow', 'active'), queryFn: async () => { - const rows = await listFoldersForWorkspace(workspaceId, 'active') + const rows = await listFoldersForWorkspace(workspaceId, 'workflow', 'active') return rows.map(mapFolder) }, staleTime: FOLDER_LIST_STALE_TIME, diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted.tsx index e1f79ef2cd5..4fbff8ba217 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted.tsx @@ -26,13 +26,9 @@ import { useFolders, useRestoreFolder } from '@/hooks/queries/folders' import { useKnowledgeBasesQuery, useRestoreKnowledgeBase } from '@/hooks/queries/kb/knowledge' import { useRestoreTable, useTablesList } from '@/hooks/queries/tables' import { useRestoreWorkflow, useWorkflows } from '@/hooks/queries/workflows' -import { - useRestoreWorkspaceFileFolder, - useWorkspaceFileFolders, -} from '@/hooks/queries/workspace-file-folders' import { useRestoreWorkspaceFile, useWorkspaceFiles } from '@/hooks/queries/workspace-files' import { useFolderStore } from '@/stores/folders/store' -import type { WorkflowFolder } from '@/stores/folders/types' +import type { Folder as FolderType } from '@/stores/folders/types' type ResourceType = | 'all' @@ -42,6 +38,8 @@ type ResourceType = | 'file' | 'folder' | 'workspace_folder' + | 'table_folder' + | 'knowledge_base_folder' function getResourceHref( workspaceId: string, @@ -62,6 +60,10 @@ function getResourceHref( return `${base}/w` case 'workspace_folder': return `${base}/files?folderId=${id}` + case 'table_folder': + return `${base}/tables?folderId=${id}` + case 'knowledge_base_folder': + return `${base}/knowledge?folderId=${id}` } } @@ -100,7 +102,6 @@ interface DeletedResource { type: Exclude deletedAt: Date workspaceId: string - color?: string } interface RestoredResourceEntry { @@ -122,7 +123,9 @@ const TYPE_LABEL: Record, string> = { folder: 'Folder', workspace_folder: 'File Folder', table: 'Table', + table_folder: 'Table Folder', knowledge: 'Knowledge Base', + knowledge_base_folder: 'Knowledge Base Folder', file: 'File', } @@ -131,9 +134,13 @@ function ResourceIcon({ resource }: { resource: DeletedResource }) { return } - if (resource.type === 'folder' || resource.type === 'workspace_folder') { - const color = resource.color ?? '#6B7280' - return + if ( + resource.type === 'folder' || + resource.type === 'workspace_folder' || + resource.type === 'table_folder' || + resource.type === 'knowledge_base_folder' + ) { + return } const mothershipType = RESOURCE_TYPE_TO_MOTHERSHIP[resource.type] @@ -148,6 +155,9 @@ function ResourceIcon({ resource }: { resource: DeletedResource }) { function matchesActiveTab(resource: DeletedResource, activeTab: ResourceType): boolean { if (activeTab === 'all') return true if (activeTab === 'file') return resource.type === 'file' || resource.type === 'workspace_folder' + if (activeTab === 'table') return resource.type === 'table' || resource.type === 'table_folder' + if (activeTab === 'knowledge') + return resource.type === 'knowledge' || resource.type === 'knowledge_base_folder' return resource.type === activeTab } @@ -195,14 +205,24 @@ export function RecentlyDeleted() { const tablesQuery = useTablesList(workspaceId, 'archived') const knowledgeQuery = useKnowledgeBasesQuery(workspaceId, { scope: 'archived' }) const filesQuery = useWorkspaceFiles(workspaceId, 'archived') - const workspaceFoldersQuery = useWorkspaceFileFolders(workspaceId, 'archived') + const workspaceFoldersQuery = useFolders(workspaceId, { + resourceType: 'file', + scope: 'archived', + }) + const tableFoldersQuery = useFolders(workspaceId, { + resourceType: 'table', + scope: 'archived', + }) + const knowledgeFoldersQuery = useFolders(workspaceId, { + resourceType: 'knowledge_base', + scope: 'archived', + }) const restoreWorkflow = useRestoreWorkflow() const restoreFolder = useRestoreFolder() const restoreTable = useRestoreTable() const restoreKnowledgeBase = useRestoreKnowledgeBase() const restoreWorkspaceFile = useRestoreWorkspaceFile() - const restoreWorkspaceFileFolder = useRestoreWorkspaceFileFolder() const isLoading = workflowsQuery.isLoading || @@ -210,7 +230,9 @@ export function RecentlyDeleted() { tablesQuery.isLoading || knowledgeQuery.isLoading || filesQuery.isLoading || - workspaceFoldersQuery.isLoading + workspaceFoldersQuery.isLoading || + tableFoldersQuery.isLoading || + knowledgeFoldersQuery.isLoading const error = workflowsQuery.error || @@ -218,7 +240,9 @@ export function RecentlyDeleted() { tablesQuery.error || knowledgeQuery.error || filesQuery.error || - workspaceFoldersQuery.error + workspaceFoldersQuery.error || + tableFoldersQuery.error || + knowledgeFoldersQuery.error const resources = useMemo(() => { const items: DeletedResource[] = [] @@ -238,9 +262,8 @@ export function RecentlyDeleted() { id: folder.id, name: folder.name, type: 'folder', - deletedAt: folder.archivedAt ? new Date(folder.archivedAt) : new Date(folder.updatedAt), + deletedAt: folder.deletedAt ? new Date(folder.deletedAt) : new Date(folder.updatedAt), workspaceId: folder.workspaceId, - color: folder.color, }) } @@ -284,6 +307,26 @@ export function RecentlyDeleted() { }) } + for (const tf of tableFoldersQuery.data ?? []) { + items.push({ + id: tf.id, + name: tf.name, + type: 'table_folder', + deletedAt: tf.deletedAt ? new Date(tf.deletedAt) : new Date(tf.updatedAt), + workspaceId: tf.workspaceId, + }) + } + + for (const kf of knowledgeFoldersQuery.data ?? []) { + items.push({ + id: kf.id, + name: kf.name, + type: 'knowledge_base_folder', + deletedAt: kf.deletedAt ? new Date(kf.deletedAt) : new Date(kf.updatedAt), + workspaceId: kf.workspaceId, + }) + } + return items }, [ workflowsQuery.data, @@ -292,6 +335,8 @@ export function RecentlyDeleted() { knowledgeQuery.data, filesQuery.data, workspaceFoldersQuery.data, + tableFoldersQuery.data, + knowledgeFoldersQuery.data, workspaceId, ]) @@ -340,10 +385,10 @@ export function RecentlyDeleted() { function handleView(resource: DeletedResource) { if (resource.type === 'folder') { const setExpanded = useFolderStore.getState().setExpanded - const byId = new Map() + const byId = new Map() for (const folder of foldersQuery.data ?? []) byId.set(folder.id, folder) for (const folder of activeFoldersQuery.data ?? []) byId.set(folder.id, folder) - let current: WorkflowFolder | undefined = byId.get(resource.id) + let current: FolderType | undefined = byId.get(resource.id) const seen = new Set() while (current && !seen.has(current.id)) { seen.add(current.id) @@ -389,9 +434,24 @@ export function RecentlyDeleted() { }) break case 'workspace_folder': - await restoreWorkspaceFileFolder.mutateAsync({ + await restoreFolder.mutateAsync({ + workspaceId: resource.workspaceId, + folderId: resource.id, + resourceType: 'file', + }) + break + case 'table_folder': + await restoreFolder.mutateAsync({ + workspaceId: resource.workspaceId, + folderId: resource.id, + resourceType: 'table', + }) + break + case 'knowledge_base_folder': + await restoreFolder.mutateAsync({ workspaceId: resource.workspaceId, folderId: resource.id, + resourceType: 'knowledge_base', }) break } diff --git a/apps/sim/app/workspace/[workspaceId]/tables/components/table-context-menu/table-context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/tables/components/table-context-menu/table-context-menu.tsx index b8064ef6627..f7ce6b91ca7 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/components/table-context-menu/table-context-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/components/table-context-menu/table-context-menu.tsx @@ -8,7 +8,7 @@ import { DropdownMenuTrigger, Upload, } from '@sim/emcn' -import { Database, Download, Duplicate, Pencil, Trash } from '@sim/emcn/icons' +import { Database, Download, Duplicate, Lock, Pencil, Trash, Unlock } from '@sim/emcn/icons' interface TableContextMenuProps { isOpen: boolean @@ -20,10 +20,14 @@ interface TableContextMenuProps { onRename?: () => void onImportCsv?: () => void onExportCsv?: () => void + onToggleLock?: () => void disableDelete?: boolean disableRename?: boolean disableImport?: boolean disableExport?: boolean + showLock?: boolean + disableLock?: boolean + isLocked?: boolean menuRef?: React.RefObject } @@ -37,10 +41,14 @@ export function TableContextMenu({ onRename, onImportCsv, onExportCsv, + onToggleLock, disableDelete = false, disableRename = false, disableImport = false, disableExport = false, + showLock = false, + disableLock = false, + isLocked = false, }: TableContextMenuProps) { return ( !open && onClose()} modal={false}> @@ -88,9 +96,14 @@ export function TableContextMenu({ Export CSV )} - {(onViewSchema || onRename || onImportCsv || onExportCsv) && (onCopyId || onDelete) && ( - + {showLock && onToggleLock && ( + + {isLocked ? : } + {isLocked ? 'Unlock' : 'Lock'} + )} + {(onViewSchema || onRename || onImportCsv || onExportCsv || (showLock && onToggleLock)) && + (onCopyId || onDelete) && } {onCopyId && ( diff --git a/apps/sim/app/workspace/[workspaceId]/tables/components/tables-list-context-menu/tables-list-context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/tables/components/tables-list-context-menu/tables-list-context-menu.tsx index f0925f7cc6c..14661f66213 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/components/tables-list-context-menu/tables-list-context-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/components/tables-list-context-menu/tables-list-context-menu.tsx @@ -1,21 +1,17 @@ 'use client' -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, - Upload, -} from '@sim/emcn' -import { Plus } from '@sim/emcn/icons' +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@sim/emcn' +import { FolderPlus, Plus, Upload } from '@sim/emcn/icons' interface TablesListContextMenuProps { isOpen: boolean position: { x: number; y: number } onClose: () => void onCreateTable?: () => void + onCreateFolder?: () => void onUploadCsv?: () => void disableCreate?: boolean + disableCreateFolder?: boolean disableUpload?: boolean } @@ -24,22 +20,18 @@ export function TablesListContextMenu({ position, onClose, onCreateTable, + onCreateFolder, onUploadCsv, disableCreate = false, + disableCreateFolder = false, disableUpload = false, }: TablesListContextMenuProps) { return ( !open && onClose()} modal={false}>
@@ -56,6 +48,12 @@ export function TablesListContextMenu({ Create table )} + {onCreateFolder && ( + + + New folder + + )} {onUploadCsv && ( diff --git a/apps/sim/app/workspace/[workspaceId]/tables/search-params.ts b/apps/sim/app/workspace/[workspaceId]/tables/search-params.ts index 461dc62f005..0155cbfcc59 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/search-params.ts +++ b/apps/sim/app/workspace/[workspaceId]/tables/search-params.ts @@ -45,3 +45,20 @@ export const tablesUrlKeys = { history: 'replace', clearOnDefault: true, } as const + +/** + * `folderId` scopes the list to a folder and is a navigation destination, not a + * filter — it lands in browser history so back/forward moves between folders. + * Kept as a standalone `useQueryState` (via {@link tableFolderIdParam}) rather + * than folded into {@link tablesParsers}, since that group's shared options are + * `history: 'replace'`. + */ +export const tableFolderIdParam = { + key: 'folderId', + parser: parseAsString, +} as const + +export const tableFolderIdUrlKeys = { + history: 'push', + clearOnDefault: true, +} as const diff --git a/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx b/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx index c88ae332b2b..9c732079668 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx @@ -1,13 +1,26 @@ 'use client' -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react' import type { ComboboxOption } from '@sim/emcn' -import { ChipCombobox, ChipConfirmModal, Plus, toast, Upload } from '@sim/emcn' -import { Columns3, Rows3, Table as TableIcon } from '@sim/emcn/icons' +import { + ChipCombobox, + ChipConfirmModal, + cellIconNodeClass, + chipContentGap, + chipContentLabelClass, + cn, + Folder, + FolderPlus, + Plus, + toast, + Upload, +} from '@sim/emcn' +import { Columns3, Lock, Rows3, Table as TableIcon } from '@sim/emcn/icons' import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { useParams, useRouter } from 'next/navigation' -import { debounce, useQueryStates } from 'nuqs' +import { debounce, useQueryState, useQueryStates } from 'nuqs' +import { PinButton } from '@/components/folders/pin-button' import type { TableDefinition } from '@/lib/table' import { CSV_ASYNC_IMPORT_THRESHOLD_BYTES, generateUniqueTableName } from '@/lib/table/constants' import type { @@ -18,7 +31,13 @@ import type { SearchConfig, SortConfig, } from '@/app/workspace/[workspaceId]/components' -import { ownerCell, Resource, timeCell } from '@/app/workspace/[workspaceId]/components' +import { + EMPTY_CELL_PLACEHOLDER, + FloatingOverflowText, + ownerCell, + Resource, + timeCell, +} from '@/app/workspace/[workspaceId]/components' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import { ImportCsvDialog, @@ -31,10 +50,19 @@ import { DEFAULT_TABLE_SORT_DIRECTION, TABLE_SORT_COLUMNS, type TableSortColumn, + tableFolderIdParam, + tableFolderIdUrlKeys, tablesParsers, tablesUrlKeys, } from '@/app/workspace/[workspaceId]/tables/search-params' import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks' +import { + useCreateFolder, + useDeleteFolderMutation, + useFolders, + useUpdateFolder, +} from '@/hooks/queries/folders' +import { usePinnedIds } from '@/hooks/queries/pinned-items' import { cancelTableJob, downloadTableExport, @@ -45,10 +73,14 @@ import { useTablesList, useUploadCsvToTable, } from '@/hooks/queries/tables' -import { useWorkspaceMembersQuery } from '@/hooks/queries/workspace' +import { isFolderOrAncestorLocked } from '@/hooks/queries/utils/folder-tree' +import { useWorkspaceMembersQuery, type WorkspaceMember } from '@/hooks/queries/workspace' import { useDebounce } from '@/hooks/use-debounce' +import { useFolderBreadcrumbs } from '@/hooks/use-folder-breadcrumbs' +import { useFolderCreateWithDedup } from '@/hooks/use-folder-create-with-dedup' import { useInlineRename } from '@/hooks/use-inline-rename' import { usePermissionConfig } from '@/hooks/use-permission-config' +import type { Folder as FolderType } from '@/stores/folders/types' import { useImportTrayStore } from '@/stores/table/import-tray/store' const logger = createLogger('Tables') @@ -65,6 +97,70 @@ const COLUMNS: ResourceColumn[] = [ { id: 'updated', header: 'Last Updated' }, ] +const EMPTY_TABLE_FOLDERS: FolderType[] = [] + +const tableRowId = (id: string) => `table:${id}` +const folderRowId = (id: string) => `folder:${id}` +const parseRowId = (rowId: string): { kind: 'table' | 'folder'; id: string } => { + if (rowId.startsWith('folder:')) return { kind: 'folder', id: rowId.slice('folder:'.length) } + if (rowId.startsWith('table:')) return { kind: 'table', id: rowId.slice('table:'.length) } + return { kind: 'table', id: rowId } +} + +interface NameCellContentProps { + icon: ReactNode + label: string + workspaceId: string + resourceType: 'table' | 'folder' + resourceId: string + pinned: boolean + locked?: boolean +} + +/** + * Reproduces the default name-cell layout (icon + truncating label) plus a + * trailing hover-revealed {@link PinButton} — used instead of the default + * icon/label cell rendering so pinning doesn't need a dedicated column (which + * would shift `buildGridTemplateColumns` width math). Renders a small lock + * indicator when the resource is directly locked (mirrors the workflow + * sidebar's row lock icon — inherited-from-folder lock is not iconified here). + */ +function NameCellContent({ + icon, + label, + workspaceId, + resourceType, + resourceId, + pinned, + locked = false, +}: NameCellContentProps) { + return ( + + + {icon} + + + + {locked && ( + + + )} + + + + ) +} + export function Tables() { const params = useParams() const router = useRouter() @@ -78,9 +174,18 @@ export function Tables() { }, [permissionConfig.hideTablesTab, router, workspaceId]) const userPermissions = useUserPermissionsContext() + const canEdit = userPermissions.canEdit === true const { data: tables = [], error } = useTablesList(workspaceId) + const { data: folders = EMPTY_TABLE_FOLDERS } = useFolders(workspaceId, { resourceType: 'table' }) const { data: members } = useWorkspaceMembersQuery(workspaceId) + const membersById = useMemo(() => { + const map = new Map() + for (const member of members ?? []) map.set(member.userId, member) + return map + }, [members]) + const pinnedTableIds = usePinnedIds(workspaceId, 'table') + const pinnedFolderIds = usePinnedIds(workspaceId, 'folder') if (error) { logger.error('Failed to load tables:', error) @@ -88,16 +193,46 @@ export function Tables() { const deleteTable = useDeleteTable(workspaceId) const renameTable = useRenameTable(workspaceId) const createTable = useCreateTable(workspaceId) + const createFolder = useCreateFolder() + const updateFolder = useUpdateFolder() + const deleteFolder = useDeleteFolderMutation() const uploadCsv = useUploadCsvToTable() const importCsvAsync = useImportCsvAsync() - const tableRename = useInlineRename({ - onSave: (tableId, name) => renameTable.mutateAsync({ tableId, name }), + const [currentFolderId, setTableFolderId] = useQueryState(tableFolderIdParam.key, { + ...tableFolderIdParam.parser, + ...tableFolderIdUrlKeys, + }) + + const listRename = useInlineRename({ + onSave: (rowId, name) => { + const parsed = parseRowId(rowId) + if (parsed.kind === 'folder') { + return updateFolder.mutateAsync({ + workspaceId, + resourceType: 'table', + id: parsed.id, + updates: { name }, + }) + } + return renameTable.mutateAsync({ tableId: parsed.id, name }) + }, + }) + + const breadcrumbRename = useInlineRename({ + onSave: (folderId, name) => + updateFolder.mutateAsync({ + workspaceId, + resourceType: 'table', + id: folderId, + updates: { name }, + }), }) const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false) const [isImportDialogOpen, setIsImportDialogOpen] = useState(false) const [activeTable, setActiveTable] = useState(null) + const [activeFolderId, setActiveFolderId] = useState(null) const [ { @@ -167,10 +302,42 @@ export function Tables() { closeMenu: closeRowContextMenu, } = useContextMenu() + const folderById = useMemo(() => new Map(folders.map((folder) => [folder.id, folder])), [folders]) + + const activeFolder = activeFolderId ? (folderById.get(activeFolderId) ?? null) : null + + const visibleFolders = useMemo(() => { + const siblings = folders.filter((folder) => (folder.parentId ?? null) === currentFolderId) + const searched = debouncedSearchTerm + ? siblings.filter((folder) => + folder.name.toLowerCase().includes(debouncedSearchTerm.toLowerCase()) + ) + : siblings + const col = + activeSort?.column === 'created' || activeSort?.column === 'updated' + ? activeSort.column + : 'name' + const dir = activeSort?.direction ?? 'asc' + return [...searched].sort((a, b) => { + let cmp = 0 + if (col === 'updated') { + cmp = new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime() + } else if (col === 'created') { + cmp = new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime() + } else { + cmp = a.name.localeCompare(b.name) + } + return dir === 'asc' ? cmp : -cmp + }) + }, [folders, currentFolderId, debouncedSearchTerm, activeSort]) + const processedTables = useMemo(() => { - let result = debouncedSearchTerm - ? tables.filter((t) => t.name.toLowerCase().includes(debouncedSearchTerm.toLowerCase())) - : tables + let result = tables.filter((t) => (t.folderId ?? null) === currentFolderId) + if (debouncedSearchTerm) { + result = result.filter((t) => + t.name.toLowerCase().includes(debouncedSearchTerm.toLowerCase()) + ) + } if (rowCountFilter.length > 0) { result = result.filter((t) => { @@ -204,57 +371,104 @@ export function Tables() { cmp = new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime() break case 'owner': { - const aName = members?.find((m) => m.userId === a.createdBy)?.name ?? '' - const bName = members?.find((m) => m.userId === b.createdBy)?.name ?? '' + const aName = membersById.get(a.createdBy)?.name ?? '' + const bName = membersById.get(b.createdBy)?.name ?? '' cmp = aName.localeCompare(bName) break } } return dir === 'asc' ? cmp : -cmp }) - }, [tables, debouncedSearchTerm, rowCountFilter, ownerFilter, activeSort, members]) + }, [ + tables, + currentFolderId, + debouncedSearchTerm, + rowCountFilter, + ownerFilter, + activeSort, + membersById, + ]) - const rows: ResourceRow[] = useMemo( - () => - processedTables.map((table) => ({ - id: table.id, + const baseRows: ResourceRow[] = useMemo(() => { + const folderRows = visibleFolders.map((folder) => ({ + id: folderRowId(folder.id), + cells: { + name: { + content: ( + } + label={folder.name} + workspaceId={workspaceId} + resourceType='folder' + resourceId={folder.id} + pinned={pinnedFolderIds.has(folder.id)} + locked={folder.locked} + /> + ), + }, + columns: { label: EMPTY_CELL_PLACEHOLDER }, + rows: { label: EMPTY_CELL_PLACEHOLDER }, + created: timeCell(folder.createdAt), + owner: ownerCell(folder.userId, membersById), + updated: timeCell(folder.updatedAt), + }, + })) + + const tableRows = processedTables.map((table) => ({ + id: tableRowId(table.id), + cells: { + name: { + content: ( + } + label={table.name} + workspaceId={workspaceId} + resourceType='table' + resourceId={table.id} + pinned={pinnedTableIds.has(table.id)} + locked={table.locked} + /> + ), + }, + columns: { + icon: , + label: String(table.schema.columns.length), + }, + rows: { + icon: , + label: String(table.rowCount), + }, + created: timeCell(table.createdAt), + owner: ownerCell(table.createdBy, membersById), + updated: timeCell(table.updatedAt), + }, + })) + + return [...folderRows, ...tableRows] + }, [visibleFolders, processedTables, membersById, workspaceId, pinnedFolderIds, pinnedTableIds]) + + const rows: ResourceRow[] = useMemo(() => { + if (!listRename.editingId) return baseRows + return baseRows.map((row) => { + if (row.id !== listRename.editingId) return row + return { + ...row, cells: { + ...row.cells, name: { - icon: , - label: table.name, - editing: - tableRename.editingId === table.id - ? { - value: tableRename.editValue, - onChange: tableRename.setEditValue, - onSubmit: tableRename.submitRename, - onCancel: tableRename.cancelRename, - } - : undefined, - }, - columns: { - icon: , - label: String(table.schema.columns.length), + ...row.cells.name, + editing: { + value: listRename.editValue, + onChange: listRename.setEditValue, + onSubmit: listRename.submitRename, + onCancel: listRename.cancelRename, + disabled: listRename.isSaving, + }, }, - rows: { - icon: , - label: String(table.rowCount), - }, - created: timeCell(table.createdAt), - owner: ownerCell(table.createdBy, members), - updated: timeCell(table.updatedAt), }, - })), - [ - processedTables, - members, - tableRename.editingId, - tableRename.editValue, - tableRename.setEditValue, - tableRename.submitRename, - tableRename.cancelRename, - ] - ) + } + }) + }, [baseRows, listRename.editingId, listRename.editValue, listRename.isSaving]) const searchConfig: SearchConfig = useMemo( () => ({ @@ -436,30 +650,77 @@ export function Tables() { const handleRowClick = useCallback( (rowId: string) => { - if (!isRowContextMenuOpen) { - router.push(`/workspace/${workspaceId}/tables/${rowId}`) + if (isRowContextMenuOpen) return + const parsed = parseRowId(rowId) + if (parsed.kind === 'folder') { + void setTableFolderId(parsed.id) + return } + router.push(`/workspace/${workspaceId}/tables/${parsed.id}`) }, - [isRowContextMenuOpen, router, workspaceId] + [isRowContextMenuOpen, router, workspaceId, setTableFolderId] ) const handleRowContextMenu = useCallback( (e: React.MouseEvent, rowId: string) => { - const table = tables.find((t) => t.id === rowId) ?? null - setActiveTable(table) + const parsed = parseRowId(rowId) + if (parsed.kind === 'folder') { + setActiveFolderId(parsed.id) + setActiveTable(null) + } else { + setActiveTable(tables.find((t) => t.id === parsed.id) ?? null) + setActiveFolderId(null) + } handleRowCtxMenu(e) }, [tables, handleRowCtxMenu] ) + const activeResourceLocked = activeFolder?.locked ?? activeTable?.locked ?? false + const activeResourceInheritedLocked = isFolderOrAncestorLocked( + activeFolder ? activeFolder.parentId : (activeTable?.folderId ?? null), + Object.fromEntries(folderById) + ) + + const handleToggleLock = useCallback(() => { + if (activeResourceInheritedLocked) return + if (activeFolder) { + updateFolder.mutate({ + workspaceId, + resourceType: 'table', + id: activeFolder.id, + updates: { locked: !activeFolder.locked }, + }) + } else if (activeTable) { + renameTable.mutate({ + tableId: activeTable.id, + name: activeTable.name, + locked: !activeTable.locked, + }) + } + }, [ + activeFolder, + activeTable, + activeResourceInheritedLocked, + updateFolder, + renameTable, + workspaceId, + ]) + const handleDelete = async () => { - if (!activeTable) return try { - await deleteTable.mutateAsync(activeTable.id) + if (activeFolderId) { + await deleteFolder.mutateAsync({ workspaceId, resourceType: 'table', id: activeFolderId }) + } else if (activeTable) { + await deleteTable.mutateAsync(activeTable.id) + } else { + return + } setIsDeleteDialogOpen(false) setActiveTable(null) + setActiveFolderId(null) } catch (err) { - logger.error('Failed to delete table:', err) + logger.error('Failed to delete:', err) } } @@ -587,6 +848,7 @@ export function Tables() { columns: [{ name: 'name', type: 'string' }], }, initialRowCount: 1, + folderId: currentFolderId, }) const tableId = result?.data?.table?.id if (tableId) { @@ -595,7 +857,23 @@ export function Tables() { } catch (err) { logger.error('Failed to create table:', err) } - }, [tables, router, workspaceId, createTableAsync]) + }, [tables, router, workspaceId, createTableAsync, currentFolderId]) + + const handleFolderCreated = useCallback( + (folder: FolderType) => { + listRename.startRename(folderRowId(folder.id), folder.name) + }, + [listRename.startRename] + ) + + const handleCreateFolder = useFolderCreateWithDedup({ + workspaceId, + resourceType: 'table', + folders, + currentFolderId, + createFolder, + onCreated: handleFolderCreated, + }) const headerActions: ResourceAction[] = useMemo( () => [ @@ -603,36 +881,70 @@ export function Tables() { text: uploadButtonLabel, icon: Upload, onSelect: () => csvInputRef.current?.click(), - disabled: uploading || userPermissions.canEdit !== true, + disabled: uploading || !canEdit, + }, + { + text: 'New folder', + icon: FolderPlus, + onSelect: handleCreateFolder, + disabled: createFolder.isPending || !canEdit, }, { text: 'New table', icon: Plus, onSelect: handleCreateTable, - disabled: uploading || userPermissions.canEdit !== true || createTable.isPending, + disabled: uploading || !canEdit || createTable.isPending, variant: 'primary', }, ], [ uploadButtonLabel, uploading, - userPermissions.canEdit, + canEdit, + handleCreateFolder, + createFolder.isPending, handleCreateTable, createTable.isPending, ] ) + const handleNavigateToTables = useCallback(() => { + void setTableFolderId(null) + }, [setTableFolderId]) + + const handleNavigateToFolder = useCallback( + (folderId: string) => { + void setTableFolderId(folderId) + }, + [setTableFolderId] + ) + + const listBreadcrumbs = useFolderBreadcrumbs({ + folderById, + currentFolderId, + rootLabel: 'Tables', + onNavigateRoot: handleNavigateToTables, + onNavigateFolder: handleNavigateToFolder, + breadcrumbRename, + canEdit, + canEditLoading: userPermissions.isLoading, + }) + // Stable identities so the memoized Resource.Header / Resource.Options can // actually bail — inline object/element props would defeat their memo. const headerAside = useMemo(() => , [workspaceId]) const filterConfig = useMemo(() => ({ content: filterContent }), [filterContent]) + const isDeletingFolder = activeFolder != null + const deleteTitle = isDeletingFolder ? 'Delete Folder' : 'Delete Table' + return ( <> @@ -665,35 +977,44 @@ export function Tables() { position={listContextMenuPosition} onClose={closeListContextMenu} onCreateTable={handleCreateTable} + onCreateFolder={handleCreateFolder} onUploadCsv={handleListUploadCsv} - disableCreate={userPermissions.canEdit !== true || createTable.isPending} - disableUpload={uploading || userPermissions.canEdit !== true} + disableCreate={!canEdit || createTable.isPending} + disableCreateFolder={!canEdit || createFolder.isPending} + disableUpload={uploading || !canEdit} /> { - if (activeTable) navigator.clipboard.writeText(activeTable.id) - }} + onCopyId={activeTable ? () => navigator.clipboard.writeText(activeTable.id) : undefined} onDelete={() => setIsDeleteDialogOpen(true)} onRename={() => { - if (activeTable) tableRename.startRename(activeTable.id, activeTable.name) - }} - onImportCsv={() => setIsImportDialogOpen(true)} - onExportCsv={async () => { - if (!activeTable) return - try { - await downloadTableExport(activeTable.id, activeTable.name) - } catch (err) { - logger.error('Failed to export table:', err) - toast.error('Failed to export table') - } + if (activeTable) listRename.startRename(tableRowId(activeTable.id), activeTable.name) + if (activeFolder) listRename.startRename(folderRowId(activeFolder.id), activeFolder.name) }} - disableDelete={userPermissions.canEdit !== true} - disableRename={userPermissions.canEdit !== true} - disableImport={userPermissions.canEdit !== true} + onImportCsv={activeTable ? () => setIsImportDialogOpen(true) : undefined} + onExportCsv={ + activeTable + ? async () => { + if (!activeTable) return + try { + await downloadTableExport(activeTable.id, activeTable.name) + } catch (err) { + logger.error('Failed to export table:', err) + toast.error('Failed to export table') + } + } + : undefined + } + disableDelete={!canEdit} + disableRename={!canEdit} + disableImport={!canEdit} + onToggleLock={handleToggleLock} + showLock={Boolean(activeFolder || activeTable)} + disableLock={!userPermissions.canAdmin || activeResourceInheritedLocked} + isLocked={activeResourceLocked} /> {activeTable && ( @@ -712,21 +1033,34 @@ export function Tables() { open={isDeleteDialogOpen} onOpenChange={(open) => { setIsDeleteDialogOpen(open) - if (!open) setActiveTable(null) + if (!open) { + setActiveTable(null) + setActiveFolderId(null) + } }} - srTitle='Delete Table' - title='Delete Table' - text={[ - 'Are you sure you want to delete ', - { text: activeTable?.name ?? 'this table', bold: true }, - '? ', - { text: `All ${activeTable?.rowCount ?? 0} rows will be removed.`, error: true }, - ' You can restore it from Recently Deleted in Settings.', - ]} + srTitle={deleteTitle} + title={deleteTitle} + text={ + isDeletingFolder + ? [ + 'Are you sure you want to delete ', + { text: activeFolder?.name ?? 'this folder', bold: true }, + '? ', + { text: 'This will also delete tables inside it.', error: true }, + ' You can restore it from Recently Deleted in Settings.', + ] + : [ + 'Are you sure you want to delete ', + { text: activeTable?.name ?? 'this table', bold: true }, + '? ', + { text: `All ${activeTable?.rowCount ?? 0} rows will be removed.`, error: true }, + ' You can restore it from Recently Deleted in Settings.', + ] + } confirm={{ label: 'Delete', onClick: handleDelete, - pending: deleteTable.isPending, + pending: deleteTable.isPending || deleteFolder.isPending, pendingLabel: 'Deleting...', }} /> diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/editor.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/editor.tsx index bd9558e6789..a9f05277bf4 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/editor.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/editor.tsx @@ -53,7 +53,7 @@ import { PreviewWorkflow } from '@/app/workspace/[workspaceId]/w/components/prev import { getTileIconColorClass } from '@/blocks/icon-color' import { getBlock } from '@/blocks/registry' import { useFolderMap } from '@/hooks/queries/folders' -import { isWorkflowEffectivelyLocked } from '@/hooks/queries/utils/folder-tree' +import { isResourceEffectivelyLocked } from '@/hooks/queries/utils/folder-tree' import { useWorkflowMap, useWorkflowState } from '@/hooks/queries/workflows' import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow' import { usePanelEditorSearchStore, usePanelEditorStore } from '@/stores/panel' @@ -129,7 +129,7 @@ export function Editor() { const { data: workflows = {} } = useWorkflowMap(workspaceId) const { data: folders = {} } = useFolderMap(workspaceId) const workflowMetadata = workflowId ? workflows[workflowId] : undefined - const workflowLocked = isWorkflowEffectivelyLocked(workflowMetadata, folders) + const workflowLocked = isResourceEffectivelyLocked(workflowMetadata, folders) // Check if block is locked (or inside a locked ancestor) and compute edit permission // Locked blocks cannot be edited by anyone (admins can only lock/unlock) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx index 7871c4e6ec3..f45fd5be7e0 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx @@ -74,7 +74,7 @@ import { useCopilotChats, } from '@/hooks/queries/copilot-chats' import { useFolderMap } from '@/hooks/queries/folders' -import { isWorkflowEffectivelyLocked } from '@/hooks/queries/utils/folder-tree' +import { isResourceEffectivelyLocked } from '@/hooks/queries/utils/folder-tree' import { useDuplicateWorkflowMutation, useWorkflowMap } from '@/hooks/queries/workflows' import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow' import { usePermissionConfig } from '@/hooks/use-permission-config' @@ -232,7 +232,7 @@ export const Panel = memo(function Panel() { ) const currentWorkflow = activeWorkflowId ? workflows[activeWorkflowId] : null - const workflowLocked = isWorkflowEffectivelyLocked(currentWorkflow, folders) + const workflowLocked = isResourceEffectivelyLocked(currentWorkflow, folders) const canMutateWorkflow = userPermissions.canEdit && !workflowLocked const { isSnapshotView } = useCurrentWorkflow() diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/search-replace/workflow-search-replace.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/search-replace/workflow-search-replace.tsx index 650170cae4a..4d980f52787 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/search-replace/workflow-search-replace.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/search-replace/workflow-search-replace.tsx @@ -40,7 +40,7 @@ import { useMcpTools } from '@/hooks/mcp/use-mcp-tools' import { useWorkspaceCredentials } from '@/hooks/queries/credentials' import { useCustomTools } from '@/hooks/queries/custom-tools' import { useFolderMap } from '@/hooks/queries/folders' -import { isWorkflowEffectivelyLocked } from '@/hooks/queries/utils/folder-tree' +import { isResourceEffectivelyLocked } from '@/hooks/queries/utils/folder-tree' import { useWorkflowMap } from '@/hooks/queries/workflows' import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow' import { usePanelEditorSearchStore, usePanelEditorStore } from '@/stores/panel' @@ -122,7 +122,7 @@ export function WorkflowSearchReplace() { const { data: workflows = {} } = useWorkflowMap(workspaceId) const { data: folders = {} } = useFolderMap(workspaceId) const workflowMetadata = workflowId ? workflows[workflowId] : undefined - const workflowLocked = isWorkflowEffectivelyLocked(workflowMetadata, folders) + const workflowLocked = isResourceEffectivelyLocked(workflowMetadata, folders) const searchReadOnly = currentWorkflow.isSnapshotView || workflowLocked const readonlyReason = currentWorkflow.isSnapshotView ? 'Snapshot view is readonly' diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/collapsed-sidebar-menu/collapsed-sidebar-menu.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/collapsed-sidebar-menu/collapsed-sidebar-menu.tsx index 649b523f9bd..5643b59ffcf 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/collapsed-sidebar-menu/collapsed-sidebar-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/collapsed-sidebar-menu/collapsed-sidebar-menu.tsx @@ -18,11 +18,10 @@ import Link from 'next/link' import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' import { ConversationListItem } from '@/app/workspace/[workspaceId]/components' import type { useHoverMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks' -import type { WorkspaceFileFolderApi } from '@/hooks/queries/workspace-file-folders' -import type { FolderTreeNode } from '@/stores/folders/types' +import type { FolderTreeNode, Folder as FolderType } from '@/stores/folders/types' import type { WorkflowMetadata } from '@/stores/workflows/registry/types' -interface FileFolderFlyoutNode extends WorkspaceFileFolderApi { +interface FileFolderFlyoutNode extends FolderType { children: FileFolderFlyoutNode[] files: WorkspaceFileRecord[] } diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/file-list/file-list.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/file-list/file-list.tsx index bb162d2c44d..9d5a116d9d7 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/file-list/file-list.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/file-list/file-list.tsx @@ -5,15 +5,15 @@ import { cn } from '@sim/emcn' import { ChevronRight } from 'lucide-react' import Link from 'next/link' import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' -import type { WorkspaceFileFolderApi } from '@/hooks/queries/workspace-file-folders' +import type { Folder } from '@/stores/folders/types' -interface FileFolderNode extends WorkspaceFileFolderApi { +interface FileFolderNode extends Folder { children: FileFolderNode[] files: WorkspaceFileRecord[] } function buildFileFolderTree( - folders: WorkspaceFileFolderApi[], + folders: Folder[], files: WorkspaceFileRecord[], parentId: string | null = null ): FileFolderNode[] { @@ -144,7 +144,7 @@ interface FileListProps { workspaceId: string currentFileId?: string pathname: string | null - folders: WorkspaceFileFolderApi[] + folders: Folder[] files: WorkspaceFileRecord[] } diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/folder-item/folder-item.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/folder-item/folder-item.tsx index cc29b050118..97bd9a7e45b 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/folder-item/folder-item.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/folder-item/folder-item.tsx @@ -8,6 +8,7 @@ import { generateId } from '@sim/utils/id' import clsx from 'clsx' import { ChevronRight, Folder, FolderOpen, MoreHorizontal } from 'lucide-react' import { useRouter } from 'next/navigation' +import { PinButton } from '@/components/folders/pin-button' import { SIM_RESOURCES_DRAG_TYPE } from '@/lib/copilot/resource-types' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import { ContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu' @@ -51,9 +52,15 @@ const logger = createLogger('FolderItem') interface FolderItemProps { workspaceId: string folder: FolderTreeNode + /** Whether this folder is pinned — computed once by the list parent from a single query. */ + pinned: boolean } -export const FolderItem = memo(function FolderItem({ workspaceId, folder }: FolderItemProps) { +export const FolderItem = memo(function FolderItem({ + workspaceId, + folder, + pinned, +}: FolderItemProps) { const { isAnyDragActive, dragDisabled, @@ -537,33 +544,42 @@ export const FolderItem = memo(function FolderItem({ workspaceId, folder }: Fold {folder.name}
-
- {folder.locked && ( - + +
+ {folder.locked && ( + + + )} + + + +
)} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/workflow-item/workflow-item.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/workflow-item/workflow-item.tsx index fcc7d8a28b8..e82dabd8817 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/workflow-item/workflow-item.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/workflow-item/workflow-item.tsx @@ -6,6 +6,7 @@ import { Lock } from '@sim/emcn/icons' import clsx from 'clsx' import { MoreHorizontal } from 'lucide-react' import Link from 'next/link' +import { PinButton } from '@/components/folders/pin-button' import { SIM_RESOURCES_DRAG_TYPE } from '@/lib/copilot/resource-types' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import { ContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu' @@ -34,7 +35,7 @@ import { useFolderMap } from '@/hooks/queries/folders' import { getFolderMap } from '@/hooks/queries/utils/folder-cache' import { isFolderOrAncestorLocked, - isWorkflowEffectivelyLocked, + isResourceEffectivelyLocked, } from '@/hooks/queries/utils/folder-tree' import { getWorkflows } from '@/hooks/queries/utils/workflow-cache' import { useUpdateWorkflow } from '@/hooks/queries/workflows' @@ -45,6 +46,8 @@ interface WorkflowItemProps { workspaceId: string workflow: WorkflowMetadata active: boolean + /** Whether this workflow is pinned — computed once by the list parent from a single query. */ + pinned: boolean } /** @@ -59,6 +62,7 @@ export const WorkflowItem = memo(function WorkflowItem({ workspaceId, workflow, active, + pinned, }: WorkflowItemProps) { const { isAnyDragActive, @@ -75,7 +79,7 @@ export const WorkflowItem = memo(function WorkflowItem({ const { data: foldersById = {} } = useFolderMap(workspaceId) const inheritedFolderLocked = isFolderOrAncestorLocked(workflow.folderId, foldersById) - const effectiveLocked = isWorkflowEffectivelyLocked(workflow, foldersById) + const effectiveLocked = isResourceEffectivelyLocked(workflow, foldersById) const { canDeleteWorkflows, canDeleteFolder } = useCanDelete({ workspaceId }) @@ -449,33 +453,42 @@ export const WorkflowItem = memo(function WorkflowItem({ {!isEditing && ( -
- {workflow.locked && ( - + +
+ {workflow.locked && ( + + + )} + + + +
)} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/workflow-list.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/workflow-list.tsx index 0110c116536..ebf3850dc0f 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/workflow-list.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/workflow-list.tsx @@ -20,6 +20,7 @@ import { groupWorkflowsByFolder, } from '@/app/workspace/[workspaceId]/w/components/sidebar/utils' import { useFolderMap, useFolders } from '@/hooks/queries/folders' +import { usePinnedItems } from '@/hooks/queries/pinned-items' import { useFolderStore } from '@/stores/folders/store' import type { FolderTreeNode } from '@/stores/folders/types' import type { WorkflowMetadata } from '@/stores/workflows/registry/types' @@ -117,6 +118,19 @@ export const WorkflowList = memo(function WorkflowList({ [workspaceId, folderMap] ) + /** Flat `id -> FolderTreeNode` lookup, used to resolve pinned folder ids against the tree. */ + const folderTreeById = useMemo(() => { + const map = new Map() + const walk = (nodes: FolderTreeNode[]) => { + for (const node of nodes) { + map.set(node.id, node) + walk(node.children) + } + } + walk(folderTree) + return map + }, [folderTree]) + const activeWorkflowFolderId = useMemo(() => { if (!workflowId || isLoading || foldersLoading) return null const activeWorkflow = regularWorkflows.find((workflow) => workflow.id === workflowId) @@ -357,6 +371,55 @@ export const WorkflowList = memo(function WorkflowList({ const isWorkflowActive = useCallback((wfId: string) => wfId === workflowId, [workflowId]) + /** + * Pinned items visible in the sidebar. Only `workflow`/`folder` pins can be + * resolved here — this surface has no file/knowledge-base/table data loaded, + * so pins from other resource types simply don't render in this section. + */ + const { data: pinnedItems } = usePinnedItems(workspaceId) + + const workflowById = useMemo(() => { + const map = new Map() + for (const wf of regularWorkflows) map.set(wf.id, wf) + return map + }, [regularWorkflows]) + + /** + * Pinned resourceIds by type, derived once from the single `pinnedItems` + * fetch above. Passed down as a `pinned` boolean prop rather than letting + * every `WorkflowItem`/`FolderItem` row subscribe to its own filtered + * query — that would both triple the network fetches (one per resource + * type) and re-render every row whenever any pin changes, defeating their + * `React.memo` wrapper. + */ + const { pinnedWorkflowIds, pinnedFolderIds } = useMemo(() => { + const workflows = new Set() + const folders = new Set() + for (const item of pinnedItems ?? []) { + if (item.resourceType === 'workflow') workflows.add(item.resourceId) + else if (item.resourceType === 'folder') folders.add(item.resourceId) + } + return { pinnedWorkflowIds: workflows, pinnedFolderIds: folders } + }, [pinnedItems]) + + const pinnedEntries = useMemo(() => { + if (!pinnedItems || pinnedItems.length === 0) return [] + const entries: Array< + | { type: 'workflow'; id: string; data: WorkflowMetadata } + | { type: 'folder'; id: string; data: FolderTreeNode } + > = [] + for (const item of pinnedItems) { + if (item.resourceType === 'workflow') { + const workflow = workflowById.get(item.resourceId) + if (workflow) entries.push({ type: 'workflow', id: workflow.id, data: workflow }) + } else if (item.resourceType === 'folder') { + const folder = folderTreeById.get(item.resourceId) + if (folder) entries.push({ type: 'folder', id: folder.id, data: folder }) + } + } + return entries + }, [pinnedItems, workflowById, folderTreeById]) + useEffect(() => { if (!workflowId || isLoading || foldersLoading) return @@ -389,13 +452,14 @@ export const WorkflowList = memo(function WorkflowList({ workspaceId={workspaceId} workflow={workflow} active={isWorkflowActive(workflow.id)} + pinned={pinnedWorkflowIds.has(workflow.id)} /> ) }, - [workspaceId, dropIndicator, isWorkflowActive, createWorkflowDragHandlers] + [workspaceId, dropIndicator, isWorkflowActive, createWorkflowDragHandlers, pinnedWorkflowIds] ) const renderFolderSection = useCallback( @@ -454,7 +518,11 @@ export const WorkflowList = memo(function WorkflowList({ style={{ paddingLeft: `${level * TREE_SPACING.INDENT_PER_LEVEL}px` }} {...createFolderDragHandlers(folder.id, parentFolderId)} > - + @@ -489,6 +557,7 @@ export const WorkflowList = memo(function WorkflowList({ createEmptyFolderDropZone, createFolderContentDropZone, renderWorkflowItem, + pinnedFolderIds, ] ) @@ -569,6 +638,33 @@ export const WorkflowList = memo(function WorkflowList({ }} data-empty-area > + {pinnedEntries.length > 0 && ( +
+
+ Pinned +
+
+ {pinnedEntries.map((entry) => + entry.type === 'workflow' ? ( + + ) : ( + + ) + )} +
+
+ )}
, - folders: Record + folders: Record ): CollectedWorkflow[] { const collectedWorkflows: CollectedWorkflow[] = [] @@ -69,7 +69,7 @@ function collectWorkflowsInFolder( */ function collectSubfolders( rootFolderId: string, - folders: Record + folders: Record ): FolderExportData[] { const subfolders: FolderExportData[] = [] diff --git a/apps/sim/app/workspace/[workspaceId]/w/hooks/use-export-selection.ts b/apps/sim/app/workspace/[workspaceId]/w/hooks/use-export-selection.ts index 0a1bf25b4d7..57fc0c5fe17 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/hooks/use-export-selection.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/hooks/use-export-selection.ts @@ -10,7 +10,7 @@ import { import { getFolderMap } from '@/hooks/queries/utils/folder-cache' import { getWorkflows } from '@/hooks/queries/utils/workflow-cache' import { useFolderStore } from '@/stores/folders/store' -import type { WorkflowFolder } from '@/stores/folders/types' +import type { Folder } from '@/stores/folders/types' import type { WorkflowMetadata } from '@/stores/workflows/registry/types' const logger = createLogger('useExportSelection') @@ -37,7 +37,7 @@ interface CollectedWorkflow { function collectWorkflowsInFolder( folderId: string, workflows: Record, - folders: Record + folders: Record ): CollectedWorkflow[] { const collectedWorkflows: CollectedWorkflow[] = [] @@ -62,7 +62,7 @@ function collectWorkflowsInFolder( */ function collectSubfoldersForMultipleFolders( rootFolderIds: string[], - folders: Record + folders: Record ): FolderExportData[] { const subfolders: FolderExportData[] = [] diff --git a/apps/sim/background/cleanup-soft-deletes.test.ts b/apps/sim/background/cleanup-soft-deletes.test.ts index a6ede324481..b67ab066db2 100644 --- a/apps/sim/background/cleanup-soft-deletes.test.ts +++ b/apps/sim/background/cleanup-soft-deletes.test.ts @@ -53,12 +53,12 @@ vi.mock('@sim/db/schema', () => { return { copilotChats: table(['id', 'workflowId']), document: table(['id', 'storageKey', 'knowledgeBaseId']), + folder: table([...softCols, 'resourceType']), knowledgeBase: table(softCols), mcpServers: table(softCols), memory: table(softCols), userTableDefinitions: table(softCols), workflow: table(softCols), - workflowFolder: table(softCols), workflowMcpServer: table(softCols), workspaceFile: table(wsFileCols), workspaceFiles: table(wsFileCols), diff --git a/apps/sim/background/cleanup-soft-deletes.ts b/apps/sim/background/cleanup-soft-deletes.ts index ee01e297898..95217ca3292 100644 --- a/apps/sim/background/cleanup-soft-deletes.ts +++ b/apps/sim/background/cleanup-soft-deletes.ts @@ -7,7 +7,7 @@ import { memory, userTableDefinitions, workflow, - workflowFolder, + folder as workflowFolder, workflowMcpServer, workspaceFile, workspaceFiles, @@ -137,10 +137,36 @@ async function cleanupWorkspaceFileStorage( const CLEANUP_TARGETS = [ { table: workflowFolder, - softDeleteCol: workflowFolder.archivedAt, + softDeleteCol: workflowFolder.deletedAt, wsCol: workflowFolder.workspaceId, + // `folder` is now a polymorphic table shared across all four resourceTypes -- + // each resourceType gets its own CLEANUP_TARGETS entry (below) scoped by + // `extraPredicate` so every resourceType's soft-deleted folders eventually get + // purged, not just workflow's. + extraPredicate: eq(workflowFolder.resourceType, 'workflow'), name: 'workflowFolder', }, + { + table: workflowFolder, + softDeleteCol: workflowFolder.deletedAt, + wsCol: workflowFolder.workspaceId, + extraPredicate: eq(workflowFolder.resourceType, 'file'), + name: 'fileFolder', + }, + { + table: workflowFolder, + softDeleteCol: workflowFolder.deletedAt, + wsCol: workflowFolder.workspaceId, + extraPredicate: eq(workflowFolder.resourceType, 'knowledge_base'), + name: 'knowledgeBaseFolder', + }, + { + table: workflowFolder, + softDeleteCol: workflowFolder.deletedAt, + wsCol: workflowFolder.workspaceId, + extraPredicate: eq(workflowFolder.resourceType, 'table'), + name: 'tableFolder', + }, { table: knowledgeBase, softDeleteCol: knowledgeBase.deletedAt, @@ -339,6 +365,7 @@ export async function runCleanupSoftDeletes(payload: CleanupJobPayload): Promise retentionDate, tableName: `${label}/${target.name}`, requireTimestampNotNull: true, + extraPredicate: 'extraPredicate' in target ? target.extraPredicate : undefined, }) totalDeleted += result.deleted } diff --git a/apps/sim/components/folders/pin-button.tsx b/apps/sim/components/folders/pin-button.tsx new file mode 100644 index 00000000000..6850ab0efe7 --- /dev/null +++ b/apps/sim/components/folders/pin-button.tsx @@ -0,0 +1,61 @@ +'use client' + +import type { MouseEvent } from 'react' +import { cn } from '@sim/emcn' +import { Pin } from '@sim/emcn/icons' +import type { PinnedResourceType } from '@/lib/api/contracts' +import { usePinItem, useUnpinItem } from '@/hooks/queries/pinned-items' + +interface PinButtonProps { + workspaceId: string + resourceType: PinnedResourceType + resourceId: string + pinned: boolean + className?: string +} + +/** + * Row-level pin toggle. Resource-type-parameterized so it works identically + * for folders, workflows, files, knowledge bases, and tables — callers pass + * whether the row is currently pinned (looked up once via `usePinnedIds`) + * rather than each row independently querying the pinned-items list. + */ +export function PinButton({ + workspaceId, + resourceType, + resourceId, + pinned, + className, +}: PinButtonProps) { + const pinItem = usePinItem() + const unpinItem = useUnpinItem() + + const handleClick = (e: MouseEvent) => { + e.preventDefault() + e.stopPropagation() + if (pinned) { + unpinItem.mutate({ workspaceId, resourceType, resourceId }) + } else { + pinItem.mutate({ workspaceId, resourceType, resourceId }) + } + } + + return ( + + ) +} diff --git a/apps/sim/ee/workspace-forking/lib/copy/copy-workflows.test.ts b/apps/sim/ee/workspace-forking/lib/copy/copy-workflows.test.ts index 330fdb2a025..d66d5a284f7 100644 --- a/apps/sim/ee/workspace-forking/lib/copy/copy-workflows.test.ts +++ b/apps/sim/ee/workspace-forking/lib/copy/copy-workflows.test.ts @@ -81,8 +81,6 @@ interface FolderRow { userId: string workspaceId: string parentId: string | null - color: string | null - isExpanded: boolean locked: boolean sortOrder: number createdAt: Date @@ -97,8 +95,6 @@ function folderRow(id: string, name: string, parentId: string | null = null): Fo userId: 'source-user', workspaceId: 'ws-source', parentId, - color: '#6B7280', - isExpanded: true, locked: false, sortOrder: 0, createdAt: new Date('2026-01-01'), diff --git a/apps/sim/ee/workspace-forking/lib/copy/copy-workflows.ts b/apps/sim/ee/workspace-forking/lib/copy/copy-workflows.ts index 2fede080ea6..3afa01b9d76 100644 --- a/apps/sim/ee/workspace-forking/lib/copy/copy-workflows.ts +++ b/apps/sim/ee/workspace-forking/lib/copy/copy-workflows.ts @@ -1,4 +1,4 @@ -import { workflow, workflowBlocks, workflowFolder } from '@sim/db/schema' +import { workflow, workflowBlocks, folder as workflowFolder } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { and, eq, inArray, isNull } from 'drizzle-orm' @@ -75,7 +75,11 @@ export async function resolveForkFolderMapping({ .select() .from(workflowFolder) .where( - and(eq(workflowFolder.workspaceId, sourceWorkspaceId), isNull(workflowFolder.archivedAt)) + and( + eq(workflowFolder.workspaceId, sourceWorkspaceId), + eq(workflowFolder.resourceType, 'workflow'), + isNull(workflowFolder.deletedAt) + ) ) if (sourceFolders.length === 0) return map @@ -98,7 +102,11 @@ export async function resolveForkFolderMapping({ .select() .from(workflowFolder) .where( - and(eq(workflowFolder.workspaceId, targetWorkspaceId), isNull(workflowFolder.archivedAt)) + and( + eq(workflowFolder.workspaceId, targetWorkspaceId), + eq(workflowFolder.resourceType, 'workflow'), + isNull(workflowFolder.deletedAt) + ) ) const targetByKey = new Map() diff --git a/apps/sim/ee/workspace-forking/lib/mapping/resources.ts b/apps/sim/ee/workspace-forking/lib/mapping/resources.ts index 2d04e0b96aa..94056302771 100644 --- a/apps/sim/ee/workspace-forking/lib/mapping/resources.ts +++ b/apps/sim/ee/workspace-forking/lib/mapping/resources.ts @@ -2,6 +2,7 @@ import { credential, customTools, document, + folder, knowledgeBase, mcpServers, skill, @@ -10,7 +11,6 @@ import { workflowDeploymentVersion, workflowMcpServer, workspaceEnvironment, - workspaceFileFolder, workspaceFiles, } from '@sim/db/schema' import { and, count, eq, exists, inArray, isNull, sql } from 'drizzle-orm' @@ -152,14 +152,15 @@ const fileCandidatesWithFolderQuery = ( key: workspaceFiles.key, label: sql`coalesce(${workspaceFiles.displayName}, ${workspaceFiles.originalName})`, folderId: workspaceFiles.folderId, - folderName: workspaceFileFolder.name, + folderName: folder.name, }) .from(workspaceFiles) .leftJoin( - workspaceFileFolder, + folder, and( - eq(workspaceFiles.folderId, workspaceFileFolder.id), - isNull(workspaceFileFolder.deletedAt) + eq(workspaceFiles.folderId, folder.id), + eq(folder.resourceType, 'file'), + isNull(folder.deletedAt) ) ) .where( diff --git a/apps/sim/hooks/queries/folders.test.ts b/apps/sim/hooks/queries/folders.test.ts index 2bb75d22466..7a5526e9d02 100644 --- a/apps/sim/hooks/queries/folders.test.ts +++ b/apps/sim/hooks/queries/folders.test.ts @@ -71,8 +71,6 @@ describe('folder optimistic top insertion ordering', () => { userId: 'user-1', workspaceId: 'ws-1', parentId: 'parent-1', - color: '#808080', - isExpanded: false, sortOrder: 5, createdAt: new Date(), updatedAt: new Date(), @@ -83,8 +81,6 @@ describe('folder optimistic top insertion ordering', () => { userId: 'user-1', workspaceId: 'ws-1', parentId: 'parent-2', - color: '#808080', - isExpanded: false, sortOrder: -100, createdAt: new Date(), updatedAt: new Date(), diff --git a/apps/sim/hooks/queries/folders.ts b/apps/sim/hooks/queries/folders.ts index 2658c303c48..238c89fab35 100644 --- a/apps/sim/hooks/queries/folders.ts +++ b/apps/sim/hooks/queries/folders.ts @@ -1,5 +1,6 @@ import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' +import type { QueryClient } from '@tanstack/react-query' import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { requestJson } from '@/lib/api/client/request' import { @@ -7,11 +8,13 @@ import { deleteFolderContract, duplicateFolderContract, type FolderApi, + type FolderResourceType, listFoldersContract, reorderFoldersContract, restoreFolderContract, updateFolderContract, } from '@/lib/api/contracts' +import { knowledgeKeys } from '@/hooks/queries/kb/knowledge' import { getFolderMap } from '@/hooks/queries/utils/folder-cache' import { type FolderQueryScope, folderKeys } from '@/hooks/queries/utils/folder-keys' import { invalidateWorkflowLists } from '@/hooks/queries/utils/invalidate-workflow-lists' @@ -19,67 +22,102 @@ import { createOptimisticMutationHandlers, generateTempId, } from '@/hooks/queries/utils/optimistic-mutation' +import { tableKeys } from '@/hooks/queries/utils/table-keys' import { getTopInsertionSortOrder } from '@/hooks/queries/utils/top-insertion-sort-order' import { getWorkflows } from '@/hooks/queries/utils/workflow-cache' -import type { WorkflowFolder } from '@/stores/folders/types' +import { workspaceFilesKeys } from '@/hooks/queries/workspace-files' +import type { Folder } from '@/stores/folders/types' + +/** + * A folder delete/restore cascades to the resources it contains -- invalidate the + * resource-type-scoped list (not just the folder list itself) so those cascaded + * changes show up without a manual refetch. + */ +function invalidateResourceListsForFolder( + queryClient: QueryClient, + workspaceId: string, + resourceType: FolderResourceType = DEFAULT_FOLDER_RESOURCE_TYPE +) { + switch (resourceType) { + case 'workflow': + return invalidateWorkflowLists(queryClient, workspaceId, ['active', 'archived']) + case 'table': + return queryClient.invalidateQueries({ queryKey: tableKeys.lists() }) + case 'knowledge_base': + return queryClient.invalidateQueries({ queryKey: knowledgeKeys.lists() }) + case 'file': + return queryClient.invalidateQueries({ queryKey: workspaceFilesKeys.lists() }) + default: + return Promise.resolve() + } +} const logger = createLogger('FolderQueries') export const FOLDER_LIST_STALE_TIME = 60 * 1000 +/** Default resourceType so the pre-existing workflow-sidebar call sites need no changes. */ +const DEFAULT_FOLDER_RESOURCE_TYPE: FolderResourceType = 'workflow' + /** - * Maps a wire folder row to the client `WorkflowFolder` shape (string dates → - * `Date`, color default). Exported so the server-side home prefetch produces - * the exact cached value `useFolders` stores, keeping the hydrated entry in - * sync with a client fetch. + * Maps a wire folder row to the client `Folder` shape (string dates → + * `Date`). Exported so the server-side home prefetch produces the exact + * cached value `useFolders` stores, keeping the hydrated entry in sync with + * a client fetch. */ -export function mapFolder(folder: FolderApi): WorkflowFolder { +export function mapFolder(folder: FolderApi): Folder { return { id: folder.id, + resourceType: folder.resourceType, name: folder.name, userId: folder.userId, workspaceId: folder.workspaceId, parentId: folder.parentId, - color: folder.color ?? '#6B7280', - isExpanded: folder.isExpanded, locked: folder.locked, sortOrder: folder.sortOrder, createdAt: new Date(folder.createdAt), updatedAt: new Date(folder.updatedAt), - archivedAt: folder.archivedAt ? new Date(folder.archivedAt) : null, + deletedAt: folder.deletedAt ? new Date(folder.deletedAt) : null, } } async function fetchFolders( workspaceId: string, + resourceType: FolderResourceType = DEFAULT_FOLDER_RESOURCE_TYPE, scope: FolderQueryScope = 'active', signal?: AbortSignal -): Promise { +): Promise { const { folders } = await requestJson(listFoldersContract, { - query: { workspaceId, scope }, + query: { workspaceId, resourceType, scope }, signal, }) return folders.map(mapFolder) } -export function useFolders(workspaceId?: string, options?: { scope?: FolderQueryScope }) { +export function useFolders( + workspaceId?: string, + options?: { resourceType?: FolderResourceType; scope?: FolderQueryScope } +) { + const resourceType = options?.resourceType ?? DEFAULT_FOLDER_RESOURCE_TYPE const scope = options?.scope ?? 'active' return useQuery({ - queryKey: folderKeys.list(workspaceId, scope), - queryFn: ({ signal }) => fetchFolders(workspaceId as string, scope, signal), + queryKey: folderKeys.list(workspaceId, resourceType, scope), + queryFn: ({ signal }) => fetchFolders(workspaceId as string, resourceType, scope, signal), enabled: Boolean(workspaceId), placeholderData: keepPreviousData, staleTime: FOLDER_LIST_STALE_TIME, }) } -const selectFolderMap = (folders: WorkflowFolder[]): Record => +const selectFolderMap = (folders: Folder[]): Record => Object.fromEntries(folders.map((folder) => [folder.id, folder])) -export function useFolderMap(workspaceId?: string) { +export function useFolderMap(workspaceId?: string, resourceType?: FolderResourceType) { + const resolvedResourceType = resourceType ?? DEFAULT_FOLDER_RESOURCE_TYPE return useQuery({ - queryKey: folderKeys.list(workspaceId), - queryFn: ({ signal }) => fetchFolders(workspaceId as string, 'active', signal), + queryKey: folderKeys.list(workspaceId, resolvedResourceType), + queryFn: ({ signal }) => + fetchFolders(workspaceId as string, resolvedResourceType, 'active', signal), enabled: Boolean(workspaceId), placeholderData: keepPreviousData, staleTime: FOLDER_LIST_STALE_TIME, @@ -89,68 +127,84 @@ export function useFolderMap(workspaceId?: string) { interface CreateFolderVariables { workspaceId: string + resourceType?: FolderResourceType name: string parentId?: string - color?: string sortOrder?: number id?: string } interface UpdateFolderVariables { workspaceId: string + resourceType?: FolderResourceType id: string - updates: Partial> + updates: Partial> } interface DeleteFolderVariables { workspaceId: string + resourceType?: FolderResourceType id: string } interface DuplicateFolderVariables { workspaceId: string + resourceType?: FolderResourceType id: string name: string parentId?: string | null - color?: string newId?: string } /** * Creates optimistic mutation handlers for folder operations */ -function createFolderMutationHandlers( +function createFolderMutationHandlers< + TVariables extends { workspaceId: string; resourceType?: FolderResourceType }, +>( queryClient: ReturnType, name: string, createOptimisticFolder: ( variables: TVariables, tempId: string, - previousFolders: Record - ) => WorkflowFolder, + previousFolders: Record + ) => Folder, customGenerateTempId?: (variables: TVariables) => string ) { - return createOptimisticMutationHandlers(queryClient, { + const queryKeyFor = (variables: Pick) => + folderKeys.list(variables.workspaceId, variables.resourceType ?? DEFAULT_FOLDER_RESOURCE_TYPE) + + return createOptimisticMutationHandlers(queryClient, { name, - getQueryKey: (variables) => folderKeys.list(variables.workspaceId), - getSnapshot: (variables) => ({ ...getFolderMap(variables.workspaceId) }), + getQueryKey: (variables) => queryKeyFor(variables), + getSnapshot: (variables) => ({ + ...getFolderMap( + variables.workspaceId, + variables.resourceType ?? DEFAULT_FOLDER_RESOURCE_TYPE + ), + }), generateTempId: customGenerateTempId ?? (() => generateTempId('temp-folder')), createOptimisticItem: (variables, tempId) => { - const previousFolders = getFolderMap(variables.workspaceId) + const previousFolders = getFolderMap( + variables.workspaceId, + variables.resourceType ?? DEFAULT_FOLDER_RESOURCE_TYPE + ) return createOptimisticFolder(variables, tempId, previousFolders) }, applyOptimisticUpdate: (tempId, item) => { - queryClient.setQueryData(folderKeys.list(item.workspaceId), (old) => [ - ...(old ?? []), - item, - ]) + queryClient.setQueryData( + folderKeys.list(item.workspaceId, item.resourceType), + (old) => [...(old ?? []), item] + ) }, replaceOptimisticEntry: (tempId, data) => { - queryClient.setQueryData(folderKeys.list(data.workspaceId), (old) => - (old ?? []).map((folder) => (folder.id === tempId ? data : folder)) + queryClient.setQueryData( + folderKeys.list(data.workspaceId, data.resourceType), + (old) => (old ?? []).map((folder) => (folder.id === tempId ? data : folder)) ) }, rollback: (snapshot, variables) => { - queryClient.setQueryData(folderKeys.list(variables.workspaceId), Object.values(snapshot)) + queryClient.setQueryData(queryKeyFor(variables), Object.values(snapshot)) }, }) } @@ -168,12 +222,11 @@ export function useCreateFolder() { return { id: tempId, + resourceType: variables.resourceType ?? DEFAULT_FOLDER_RESOURCE_TYPE, name: variables.name, userId: '', workspaceId: variables.workspaceId, parentId: variables.parentId || null, - color: variables.color || '#808080', - isExpanded: false, locked: false, sortOrder: variables.sortOrder ?? @@ -185,16 +238,26 @@ export function useCreateFolder() { ), createdAt: new Date(), updatedAt: new Date(), - archivedAt: null, + deletedAt: null, } }, (variables) => variables.id ?? generateId() ) return useMutation({ - mutationFn: async ({ workspaceId, sortOrder, ...payload }: CreateFolderVariables) => { + mutationFn: async ({ + workspaceId, + resourceType, + sortOrder, + ...payload + }: CreateFolderVariables) => { const { folder } = await requestJson(createFolderContract, { - body: { ...payload, workspaceId, sortOrder }, + body: { + ...payload, + workspaceId, + resourceType: resourceType ?? DEFAULT_FOLDER_RESOURCE_TYPE, + sortOrder, + }, }) return mapFolder(folder) }, @@ -214,7 +277,12 @@ export function useUpdateFolder() { return mapFolder(folder) }, onSettled: (_data, _error, variables) => { - queryClient.invalidateQueries({ queryKey: folderKeys.list(variables.workspaceId) }) + queryClient.invalidateQueries({ + queryKey: folderKeys.list( + variables.workspaceId, + variables.resourceType ?? DEFAULT_FOLDER_RESOURCE_TYPE + ), + }) }, }) } @@ -228,13 +296,18 @@ export function useDeleteFolderMutation() { }, onSettled: (_data, _error, variables) => { queryClient.invalidateQueries({ queryKey: folderKeys.lists() }) - return invalidateWorkflowLists(queryClient, variables.workspaceId, ['active', 'archived']) + return invalidateResourceListsForFolder( + queryClient, + variables.workspaceId, + variables.resourceType + ) }, }) } interface RestoreFolderVariables { workspaceId: string + resourceType?: FolderResourceType folderId: string } @@ -250,7 +323,11 @@ export function useRestoreFolder() { }, onSettled: (_data, _error, variables) => { queryClient.invalidateQueries({ queryKey: folderKeys.lists() }) - return invalidateWorkflowLists(queryClient, variables.workspaceId, ['active', 'archived']) + return invalidateResourceListsForFolder( + queryClient, + variables.workspaceId, + variables.resourceType + ) }, }) } @@ -270,12 +347,11 @@ export function useDuplicateFolderMutation() { const targetParentId = variables.parentId ?? sourceFolder?.parentId ?? null return { id: tempId, + resourceType: variables.resourceType ?? DEFAULT_FOLDER_RESOURCE_TYPE, name: variables.name, userId: sourceFolder?.userId || '', workspaceId: variables.workspaceId, parentId: targetParentId, - color: variables.color || sourceFolder?.color || '#808080', - isExpanded: false, locked: false, sortOrder: getTopInsertionSortOrder( currentWorkflows, @@ -285,7 +361,7 @@ export function useDuplicateFolderMutation() { ), createdAt: new Date(), updatedAt: new Date(), - archivedAt: null, + deletedAt: null, } }, (variables) => variables.newId ?? generateId() @@ -297,16 +373,14 @@ export function useDuplicateFolderMutation() { workspaceId, name, parentId, - color, newId, - }: DuplicateFolderVariables): Promise => { + }: DuplicateFolderVariables): Promise => { const { folder } = await requestJson(duplicateFolderContract, { params: { id }, body: { workspaceId, name, parentId: parentId ?? null, - color, newId, }, }) @@ -314,7 +388,12 @@ export function useDuplicateFolderMutation() { }, ...handlers, onSettled: (_data, _error, variables) => { - queryClient.invalidateQueries({ queryKey: folderKeys.list(variables.workspaceId) }) + queryClient.invalidateQueries({ + queryKey: folderKeys.list( + variables.workspaceId, + variables.resourceType ?? DEFAULT_FOLDER_RESOURCE_TYPE + ), + }) return invalidateWorkflowLists(queryClient, variables.workspaceId) }, }) @@ -322,6 +401,7 @@ export function useDuplicateFolderMutation() { interface ReorderFoldersVariables { workspaceId: string + resourceType?: FolderResourceType updates: Array<{ id: string sortOrder: number @@ -334,17 +414,20 @@ export function useReorderFolders() { return useMutation({ mutationFn: async (variables: ReorderFoldersVariables): Promise => { - await requestJson(reorderFoldersContract, { body: variables }) + const { resourceType: _resourceType, ...body } = variables + await requestJson(reorderFoldersContract, { body }) }, onMutate: async (variables) => { - await queryClient.cancelQueries({ queryKey: folderKeys.list(variables.workspaceId) }) - - const snapshot = queryClient.getQueryData( - folderKeys.list(variables.workspaceId) + const queryKey = folderKeys.list( + variables.workspaceId, + variables.resourceType ?? DEFAULT_FOLDER_RESOURCE_TYPE ) + await queryClient.cancelQueries({ queryKey }) + + const snapshot = queryClient.getQueryData(queryKey) const updatesById = new Map(variables.updates.map((update) => [update.id, update])) - queryClient.setQueryData(folderKeys.list(variables.workspaceId), (old) => { + queryClient.setQueryData(queryKey, (old) => { if (!old?.length) return old return old.map((folder) => { const update = updatesById.get(folder.id) @@ -357,15 +440,20 @@ export function useReorderFolders() { }) }) - return { snapshot } + return { snapshot, queryKey } }, - onError: (_error, variables, context) => { + onError: (_error, _variables, context) => { if (context?.snapshot) { - queryClient.setQueryData(folderKeys.list(variables.workspaceId), context.snapshot) + queryClient.setQueryData(context.queryKey, context.snapshot) } }, onSettled: (_data, _error, variables) => { - queryClient.invalidateQueries({ queryKey: folderKeys.list(variables.workspaceId) }) + queryClient.invalidateQueries({ + queryKey: folderKeys.list( + variables.workspaceId, + variables.resourceType ?? DEFAULT_FOLDER_RESOURCE_TYPE + ), + }) }, }) } diff --git a/apps/sim/hooks/queries/kb/knowledge.ts b/apps/sim/hooks/queries/kb/knowledge.ts index f31a3e00154..e6e0eb2d79a 100644 --- a/apps/sim/hooks/queries/kb/knowledge.ts +++ b/apps/sim/hooks/queries/kb/knowledge.ts @@ -607,6 +607,7 @@ interface CreateKnowledgeBaseParams { name: string description?: string workspaceId: string + folderId?: string | null chunkingConfig: { maxSize: number minSize: number @@ -643,6 +644,8 @@ interface UpdateKnowledgeBaseParams { name?: string description?: string workspaceId?: string | null + folderId?: string | null + locked?: boolean } } diff --git a/apps/sim/hooks/queries/pinned-items.test.ts b/apps/sim/hooks/queries/pinned-items.test.ts new file mode 100644 index 00000000000..e4e2ac8bf0b --- /dev/null +++ b/apps/sim/hooks/queries/pinned-items.test.ts @@ -0,0 +1,203 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { queryClient, cacheStore } = vi.hoisted(() => { + const cache = new Map() + return { + cacheStore: cache, + queryClient: { + cancelQueries: vi.fn().mockResolvedValue(undefined), + invalidateQueries: vi.fn().mockResolvedValue(undefined), + getQueryData: vi.fn((key: readonly unknown[]) => cache.get(JSON.stringify(key))), + setQueryData: vi.fn((key: readonly unknown[], updater: unknown) => { + const k = JSON.stringify(key) + const prev = cache.get(k) + const next = + typeof updater === 'function' ? (updater as (p: unknown) => unknown)(prev) : updater + cache.set(k, next) + return next + }), + getQueriesData: vi.fn( + (opts: { + queryKey: readonly unknown[] + predicate?: (query: { queryKey: readonly unknown[] }) => boolean + }) => { + const prefix = JSON.stringify(opts.queryKey).slice(0, -1) + return [...cache.entries()] + .filter(([k]) => k.startsWith(prefix)) + .map(([k, v]) => [JSON.parse(k), v] as [readonly unknown[], unknown]) + .filter(([key]) => (opts.predicate ? opts.predicate({ queryKey: key }) : true)) + } + ), + setQueriesData: vi.fn( + ( + opts: { + queryKey: readonly unknown[] + predicate?: (query: { queryKey: readonly unknown[] }) => boolean + }, + updater: unknown + ) => { + const prefix = JSON.stringify(opts.queryKey).slice(0, -1) + for (const [k, v] of [...cache.entries()]) { + if (!k.startsWith(prefix)) continue + const key = JSON.parse(k) + if (opts.predicate && !opts.predicate({ queryKey: key })) continue + const next = + typeof updater === 'function' ? (updater as (p: unknown) => unknown)(v) : updater + cache.set(k, next) + } + } + ), + }, + } +}) + +vi.mock('@tanstack/react-query', () => ({ + keepPreviousData: {}, + useQuery: vi.fn(), + useQueryClient: vi.fn(() => queryClient), + useMutation: vi.fn((options) => options), +})) + +vi.mock('@/lib/api/client/request', () => ({ + requestJson: vi.fn(), +})) + +vi.mock('@/lib/api/contracts', () => ({ + createPinnedItemContract: {}, + deletePinnedItemContract: {}, + listPinnedItemsContract: {}, +})) + +import { pinnedItemKeys, usePinItem, useUnpinItem } from '@/hooks/queries/pinned-items' + +const WORKSPACE_ID = 'ws-1' + +function setCache(key: readonly unknown[], value: unknown) { + cacheStore.set(JSON.stringify(key), value) +} + +function getCache(key: readonly unknown[]): T | undefined { + return cacheStore.get(JSON.stringify(key)) as T | undefined +} + +beforeEach(() => { + cacheStore.clear() + vi.clearAllMocks() +}) + +describe('usePinItem optimistic update', () => { + it('adds the optimistic pin to both the unscoped and matching resourceType-scoped lists, but leaves an unrelated resourceType list untouched', async () => { + setCache(pinnedItemKeys.list(WORKSPACE_ID), []) + setCache(pinnedItemKeys.list(WORKSPACE_ID, 'workflow'), []) + const otherTypeList = [ + { + id: 'pinned-existing', + userId: 'user-1', + workspaceId: WORKSPACE_ID, + resourceType: 'folder', + resourceId: 'folder-1', + pinnedAt: '2024-01-01T00:00:00.000Z', + }, + ] + setCache(pinnedItemKeys.list(WORKSPACE_ID, 'folder'), otherTypeList) + + const mutation = usePinItem() + await mutation.onMutate?.({ + workspaceId: WORKSPACE_ID, + resourceType: 'workflow', + resourceId: 'workflow-1', + }) + + const unscoped = getCache>(pinnedItemKeys.list(WORKSPACE_ID)) + const workflowScoped = getCache>( + pinnedItemKeys.list(WORKSPACE_ID, 'workflow') + ) + const folderScoped = getCache>( + pinnedItemKeys.list(WORKSPACE_ID, 'folder') + ) + + expect(unscoped?.map((i) => i.resourceId)).toEqual(['workflow-1']) + expect(workflowScoped?.map((i) => i.resourceId)).toEqual(['workflow-1']) + expect(folderScoped).toEqual(otherTypeList) + }) + + it('rolls back every affected cached list on error', async () => { + const originalUnscoped = [ + { + id: 'pinned-a', + userId: 'user-1', + workspaceId: WORKSPACE_ID, + resourceType: 'workflow', + resourceId: 'workflow-a', + pinnedAt: '2024-01-01T00:00:00.000Z', + }, + ] + setCache(pinnedItemKeys.list(WORKSPACE_ID), originalUnscoped) + setCache(pinnedItemKeys.list(WORKSPACE_ID, 'workflow'), originalUnscoped) + + const mutation = usePinItem() + const context = await mutation.onMutate?.({ + workspaceId: WORKSPACE_ID, + resourceType: 'workflow', + resourceId: 'workflow-1', + }) + + expect(getCache(pinnedItemKeys.list(WORKSPACE_ID))).not.toEqual(originalUnscoped) + + mutation.onError?.( + new Error('failed'), + { + workspaceId: WORKSPACE_ID, + resourceType: 'workflow', + resourceId: 'workflow-1', + }, + context + ) + + expect(getCache(pinnedItemKeys.list(WORKSPACE_ID))).toEqual(originalUnscoped) + expect(getCache(pinnedItemKeys.list(WORKSPACE_ID, 'workflow'))).toEqual(originalUnscoped) + }) +}) + +describe('useUnpinItem optimistic update', () => { + it('removes the pin from both the unscoped and matching resourceType-scoped lists', async () => { + const items = [ + { + id: 'pinned-1', + userId: 'user-1', + workspaceId: WORKSPACE_ID, + resourceType: 'workflow', + resourceId: 'workflow-1', + pinnedAt: '2024-01-01T00:00:00.000Z', + }, + { + id: 'pinned-2', + userId: 'user-1', + workspaceId: WORKSPACE_ID, + resourceType: 'workflow', + resourceId: 'workflow-2', + pinnedAt: '2024-01-02T00:00:00.000Z', + }, + ] + setCache(pinnedItemKeys.list(WORKSPACE_ID), items) + setCache(pinnedItemKeys.list(WORKSPACE_ID, 'workflow'), items) + + const mutation = useUnpinItem() + await mutation.onMutate?.({ + workspaceId: WORKSPACE_ID, + resourceType: 'workflow', + resourceId: 'workflow-1', + }) + + const unscoped = getCache>(pinnedItemKeys.list(WORKSPACE_ID)) + const workflowScoped = getCache>( + pinnedItemKeys.list(WORKSPACE_ID, 'workflow') + ) + + expect(unscoped?.map((i) => i.resourceId)).toEqual(['workflow-2']) + expect(workflowScoped?.map((i) => i.resourceId)).toEqual(['workflow-2']) + }) +}) diff --git a/apps/sim/hooks/queries/pinned-items.ts b/apps/sim/hooks/queries/pinned-items.ts new file mode 100644 index 00000000000..8d4bb6c2a97 --- /dev/null +++ b/apps/sim/hooks/queries/pinned-items.ts @@ -0,0 +1,224 @@ +import { useMemo } from 'react' +import { generateId } from '@sim/utils/id' +import { + keepPreviousData, + type QueryKey, + useMutation, + useQuery, + useQueryClient, +} from '@tanstack/react-query' +import { requestJson } from '@/lib/api/client/request' +import { + createPinnedItemContract, + deletePinnedItemContract, + listPinnedItemsContract, + type PinnedItemApi, + type PinnedResourceType, +} from '@/lib/api/contracts' + +export const PINNED_ITEMS_STALE_TIME = 60 * 1000 + +export const pinnedItemKeys = { + all: ['pinnedItems'] as const, + lists: () => [...pinnedItemKeys.all, 'list'] as const, + workspaceLists: (workspaceId: string | undefined) => + [...pinnedItemKeys.lists(), workspaceId ?? ''] as const, + list: (workspaceId: string | undefined, resourceType?: PinnedResourceType) => + [...pinnedItemKeys.workspaceLists(workspaceId), resourceType ?? ''] as const, +} + +async function fetchPinnedItems( + workspaceId: string, + resourceType?: PinnedResourceType, + signal?: AbortSignal +): Promise { + const { pinnedItems } = await requestJson(listPinnedItemsContract, { + query: { workspaceId, resourceType }, + signal, + }) + return pinnedItems +} + +export function usePinnedItems(workspaceId?: string, resourceType?: PinnedResourceType) { + return useQuery({ + queryKey: pinnedItemKeys.list(workspaceId, resourceType), + queryFn: ({ signal }) => fetchPinnedItems(workspaceId as string, resourceType, signal), + enabled: Boolean(workspaceId), + placeholderData: keepPreviousData, + staleTime: PINNED_ITEMS_STALE_TIME, + }) +} + +const EMPTY_PINNED_IDS: ReadonlySet = new Set() + +/** + * Pinned resourceIds for one resource type, as a `Set` for O(1) per-row + * lookups (never `.find()` per row — see `.claude/rules/sim-react-performance.md`). + */ +export function usePinnedIds( + workspaceId?: string, + resourceType?: PinnedResourceType +): ReadonlySet { + const { data } = usePinnedItems(workspaceId, resourceType) + return useMemo( + () => (data ? new Set(data.map((item) => item.resourceId)) : EMPTY_PINNED_IDS), + [data] + ) +} + +interface PinItemVariables { + workspaceId: string + resourceType: PinnedResourceType + resourceId: string +} + +interface UnpinItemVariables { + workspaceId: string + resourceType: PinnedResourceType + resourceId: string +} + +/** + * Position of the `resourceType` segment within `pinnedItemKeys.list(...)`, + * derived from the factory itself so this can't silently drift if the key + * shape changes. + */ +const RESOURCE_TYPE_KEY_INDEX = pinnedItemKeys.workspaceLists(undefined).length + +/** + * Matches the unscoped `usePinnedItems(workspaceId)` list and the + * `resourceType`-scoped list for `resourceType` — the two caches a single + * pin/unpin can appear in — while leaving lists scoped to other resource + * types untouched. + */ +function isAffectedPinnedListKey(queryKey: QueryKey, resourceType: PinnedResourceType): boolean { + const scopedType = queryKey[RESOURCE_TYPE_KEY_INDEX] + return scopedType === '' || scopedType === resourceType +} + +/** + * Snapshots every cached pinned-items list that a pin/unpin of `resourceType` + * can affect (the `resourceType`-scoped list and the unscoped + * `usePinnedItems(workspaceId)` list share the same `workspaceLists` prefix), + * so the optimistic update can later be rolled back. + */ +function snapshotPinnedListQueries( + queryClient: ReturnType, + workspaceId: string, + resourceType: PinnedResourceType +): Array<[QueryKey, PinnedItemApi[] | undefined]> { + return queryClient.getQueriesData({ + queryKey: pinnedItemKeys.workspaceLists(workspaceId), + predicate: (query) => isAffectedPinnedListKey(query.queryKey, resourceType), + }) +} + +function restorePinnedListQueries( + queryClient: ReturnType, + snapshot: Array<[QueryKey, PinnedItemApi[] | undefined]> +): void { + for (const [queryKey, data] of snapshot) { + queryClient.setQueryData(queryKey, data) + } +} + +/** + * Pins or unpins a resource with an optimistic update, following the same + * `onMutate`/`onError`/`onSettled` pattern as `useSetMothershipChatPinned`. + * Applies the optimistic change across every cached pinned-items list for + * the workspace, since a single pin/unpin can be reflected in both the + * unscoped list and a `resourceType`-scoped list simultaneously. + */ +export function usePinItem() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: async (variables: PinItemVariables) => { + const { pinnedItem } = await requestJson(createPinnedItemContract, { body: variables }) + return pinnedItem + }, + onMutate: async (variables) => { + await queryClient.cancelQueries({ + queryKey: pinnedItemKeys.workspaceLists(variables.workspaceId), + }) + + const previousQueries = snapshotPinnedListQueries( + queryClient, + variables.workspaceId, + variables.resourceType + ) + + const optimisticItem: PinnedItemApi = { + id: generateId(), + userId: '', + workspaceId: variables.workspaceId, + resourceType: variables.resourceType, + resourceId: variables.resourceId, + pinnedAt: new Date().toISOString(), + } + + queryClient.setQueriesData( + { + queryKey: pinnedItemKeys.workspaceLists(variables.workspaceId), + predicate: (query) => isAffectedPinnedListKey(query.queryKey, variables.resourceType), + }, + (old) => { + if (!old) return old + if (old.some((item) => item.resourceId === variables.resourceId)) return old + return [...old, optimisticItem] + } + ) + + return { previousQueries } + }, + onError: (_error, _variables, context) => { + if (context?.previousQueries) { + restorePinnedListQueries(queryClient, context.previousQueries) + } + }, + onSettled: (_data, _error, variables) => { + queryClient.invalidateQueries({ + queryKey: pinnedItemKeys.workspaceLists(variables.workspaceId), + }) + }, + }) +} + +export function useUnpinItem() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: async ({ resourceType, resourceId }: UnpinItemVariables) => { + return requestJson(deletePinnedItemContract, { params: { resourceType, resourceId } }) + }, + onMutate: async (variables) => { + await queryClient.cancelQueries({ + queryKey: pinnedItemKeys.workspaceLists(variables.workspaceId), + }) + + const previousQueries = snapshotPinnedListQueries( + queryClient, + variables.workspaceId, + variables.resourceType + ) + + queryClient.setQueriesData( + { + queryKey: pinnedItemKeys.workspaceLists(variables.workspaceId), + predicate: (query) => isAffectedPinnedListKey(query.queryKey, variables.resourceType), + }, + (old) => old?.filter((item) => item.resourceId !== variables.resourceId) + ) + + return { previousQueries } + }, + onError: (_error, _variables, context) => { + if (context?.previousQueries) { + restorePinnedListQueries(queryClient, context.previousQueries) + } + }, + onSettled: (_data, _error, variables) => { + queryClient.invalidateQueries({ + queryKey: pinnedItemKeys.workspaceLists(variables.workspaceId), + }) + }, + }) +} diff --git a/apps/sim/hooks/queries/tables.ts b/apps/sim/hooks/queries/tables.ts index 7510875ebbf..efdbc01f9ee 100644 --- a/apps/sim/hooks/queries/tables.ts +++ b/apps/sim/hooks/queries/tables.ts @@ -545,10 +545,20 @@ export function useRenameTable(workspaceId: string) { const queryClient = useQueryClient() return useMutation({ - mutationFn: async ({ tableId, name }: { tableId: string; name: string }) => { + mutationFn: async ({ + tableId, + name, + folderId, + locked, + }: { + tableId: string + name: string + folderId?: string | null + locked?: boolean + }) => { return requestJson(renameTableContract, { params: { tableId }, - body: { workspaceId, name }, + body: { workspaceId, name, folderId, locked }, }) }, // Inline rename reverts the field on failure with no message of its own, so diff --git a/apps/sim/hooks/queries/utils/folder-cache.ts b/apps/sim/hooks/queries/utils/folder-cache.ts index 158f558bb60..2ef3fd956f2 100644 --- a/apps/sim/hooks/queries/utils/folder-cache.ts +++ b/apps/sim/hooks/queries/utils/folder-cache.ts @@ -1,15 +1,25 @@ +import type { FolderResourceType } from '@/lib/api/contracts/folders' import { getQueryClient } from '@/app/_shell/providers/get-query-client' import { folderKeys } from '@/hooks/queries/utils/folder-keys' -import type { WorkflowFolder } from '@/stores/folders/types' +import type { Folder } from '@/stores/folders/types' -const EMPTY_FOLDERS: WorkflowFolder[] = [] +const EMPTY_FOLDERS: Folder[] = [] -export function getFolders(workspaceId: string): WorkflowFolder[] { +export function getFolders( + workspaceId: string, + resourceType: FolderResourceType = 'workflow' +): Folder[] { return ( - getQueryClient().getQueryData(folderKeys.list(workspaceId)) ?? EMPTY_FOLDERS + getQueryClient().getQueryData(folderKeys.list(workspaceId, resourceType)) ?? + EMPTY_FOLDERS ) } -export function getFolderMap(workspaceId: string): Record { - return Object.fromEntries(getFolders(workspaceId).map((folder) => [folder.id, folder])) +export function getFolderMap( + workspaceId: string, + resourceType: FolderResourceType = 'workflow' +): Record { + return Object.fromEntries( + getFolders(workspaceId, resourceType).map((folder) => [folder.id, folder]) + ) } diff --git a/apps/sim/hooks/queries/utils/folder-keys.ts b/apps/sim/hooks/queries/utils/folder-keys.ts index 2fecf659f2d..5600b2a7620 100644 --- a/apps/sim/hooks/queries/utils/folder-keys.ts +++ b/apps/sim/hooks/queries/utils/folder-keys.ts @@ -1,8 +1,19 @@ +import type { FolderResourceType } from '@/lib/api/contracts/folders' + export type FolderQueryScope = 'active' | 'archived' export const folderKeys = { all: ['folders'] as const, lists: () => [...folderKeys.all, 'list'] as const, - list: (workspaceId: string | undefined, scope: FolderQueryScope = 'active') => - [...folderKeys.lists(), workspaceId ?? '', scope] as const, + workspaceLists: (workspaceId: string | undefined) => + [...folderKeys.lists(), workspaceId ?? ''] as const, + workspaceResourceLists: ( + workspaceId: string | undefined, + resourceType: FolderResourceType = 'workflow' + ) => [...folderKeys.workspaceLists(workspaceId), resourceType] as const, + list: ( + workspaceId: string | undefined, + resourceType: FolderResourceType = 'workflow', + scope: FolderQueryScope = 'active' + ) => [...folderKeys.workspaceResourceLists(workspaceId, resourceType), scope] as const, } diff --git a/apps/sim/hooks/queries/utils/folder-tree.test.ts b/apps/sim/hooks/queries/utils/folder-tree.test.ts index 4149b4a4d7a..809d840b69b 100644 --- a/apps/sim/hooks/queries/utils/folder-tree.test.ts +++ b/apps/sim/hooks/queries/utils/folder-tree.test.ts @@ -7,19 +7,17 @@ import { getFolderPath, isFolderEffectivelyLocked, isFolderOrAncestorLocked, - isWorkflowEffectivelyLocked, + isResourceEffectivelyLocked, } from '@/hooks/queries/utils/folder-tree' -import type { WorkflowFolder } from '@/stores/folders/types' +import type { Folder } from '@/stores/folders/types' -function makeFolder(overrides: Partial & { id: string }): WorkflowFolder { +function makeFolder(overrides: Partial & { id: string }): Folder { return { id: overrides.id, name: overrides.name ?? overrides.id, userId: 'user-1', workspaceId: 'ws-1', parentId: overrides.parentId ?? null, - color: '#000000', - isExpanded: false, locked: overrides.locked ?? false, sortOrder: 0, createdAt: new Date(0), @@ -148,14 +146,14 @@ describe('findLockedAncestorFolder', () => { }) }) -describe('isWorkflowEffectivelyLocked', () => { +describe('isResourceEffectivelyLocked', () => { it('treats undefined or null workflows as unlocked', () => { - expect(isWorkflowEffectivelyLocked(undefined, {})).toBe(false) - expect(isWorkflowEffectivelyLocked(null, {})).toBe(false) + expect(isResourceEffectivelyLocked(undefined, {})).toBe(false) + expect(isResourceEffectivelyLocked(null, {})).toBe(false) }) it('returns true when the workflow row itself is locked', () => { - expect(isWorkflowEffectivelyLocked({ locked: true, folderId: null }, {})).toBe(true) + expect(isResourceEffectivelyLocked({ locked: true, folderId: null }, {})).toBe(true) }) it('returns true when an ancestor folder is locked', () => { @@ -163,16 +161,16 @@ describe('isWorkflowEffectivelyLocked', () => { eng: makeFolder({ id: 'eng', locked: true }), be: makeFolder({ id: 'be', parentId: 'eng' }), } - expect(isWorkflowEffectivelyLocked({ locked: false, folderId: 'be' }, folders)).toBe(true) + expect(isResourceEffectivelyLocked({ locked: false, folderId: 'be' }, folders)).toBe(true) }) it('returns false when neither row nor any ancestor is locked', () => { const folders = { eng: makeFolder({ id: 'eng' }) } - expect(isWorkflowEffectivelyLocked({ locked: false, folderId: 'eng' }, folders)).toBe(false) + expect(isResourceEffectivelyLocked({ locked: false, folderId: 'eng' }, folders)).toBe(false) }) it('returns false for a workflow at workspace root with no row lock', () => { - expect(isWorkflowEffectivelyLocked({ locked: false, folderId: null }, {})).toBe(false) + expect(isResourceEffectivelyLocked({ locked: false, folderId: null }, {})).toBe(false) }) }) diff --git a/apps/sim/hooks/queries/utils/folder-tree.ts b/apps/sim/hooks/queries/utils/folder-tree.ts index 39add522c57..4fb7b2d18ba 100644 --- a/apps/sim/hooks/queries/utils/folder-tree.ts +++ b/apps/sim/hooks/queries/utils/folder-tree.ts @@ -1,13 +1,14 @@ -import type { WorkflowFolder } from '@/stores/folders/types' +import type { Folder } from '@/stores/folders/types' /** * Returns true when the folder or one of its ancestors is locked. Used to * mirror server-side cascading folder lock policy on the client without an - * extra round-trip. + * extra round-trip. Generic over `Folder` (resourceType-agnostic) so it works + * for workflow, file, knowledge-base, and table folder maps alike. */ export function isFolderOrAncestorLocked( folderId: string | null | undefined, - folders: Record + folders: Record ): boolean { const visited = new Set() let currentFolderId = folderId ?? null @@ -33,7 +34,7 @@ export function isFolderOrAncestorLocked( */ export function getFolderPath( folderId: string | null | undefined, - folders: Record, + folders: Record, separator = ' / ' ): string | null { if (!folderId) return null @@ -45,7 +46,7 @@ export function getFolderPath( while (currentFolderId) { if (visited.has(currentFolderId)) break visited.add(currentFolderId) - const folder: WorkflowFolder | undefined = folders[currentFolderId] + const folder: Folder | undefined = folders[currentFolderId] if (!folder) break segments.unshift(folder.name) currentFolderId = folder.parentId @@ -61,8 +62,8 @@ export function getFolderPath( */ export function findLockedAncestorFolder( folderId: string | null | undefined, - folders: Record -): WorkflowFolder | null { + folders: Record +): Folder | null { if (!folderId) return null const visited = new Set() @@ -71,7 +72,7 @@ export function findLockedAncestorFolder( while (currentFolderId) { if (visited.has(currentFolderId)) return null visited.add(currentFolderId) - const folder: WorkflowFolder | undefined = folders[currentFolderId] + const folder: Folder | undefined = folders[currentFolderId] if (!folder) return null if (folder.locked) return folder currentFolderId = folder.parentId @@ -81,18 +82,20 @@ export function findLockedAncestorFolder( } /** - * Effective lock state for a workflow as visible to the client. Mirrors - * the server's `getWorkflowLockStatus(workflowId)` (in `@sim/platform-authz/workflow`) - * but reads from cached folder data instead of issuing DB walks. Treats an - * undefined workflow as unlocked so callers don't need to early-return. + * Effective lock state for a leaf resource (workflow, file, knowledge base, or + * table) as visible to the client. Mirrors the server's + * `getResourceLockStatus(resourceType, resourceId)` (in + * `@sim/platform-authz/resource-lock`) but reads from cached folder data + * instead of issuing DB walks. Treats an undefined resource as unlocked so + * callers don't need to early-return. */ -export function isWorkflowEffectivelyLocked( - workflow: { locked?: boolean | null; folderId?: string | null } | null | undefined, - folders: Record +export function isResourceEffectivelyLocked( + resource: { locked?: boolean | null; folderId?: string | null } | null | undefined, + folders: Record ): boolean { - if (!workflow) return false - if (workflow.locked) return true - return isFolderOrAncestorLocked(workflow.folderId, folders) + if (!resource) return false + if (resource.locked) return true + return isFolderOrAncestorLocked(resource.folderId, folders) } /** @@ -103,7 +106,7 @@ export function isWorkflowEffectivelyLocked( */ export function isFolderEffectivelyLocked( folder: { locked?: boolean | null; parentId?: string | null } | null | undefined, - folders: Record + folders: Record ): boolean { if (!folder) return false if (folder.locked) return true diff --git a/apps/sim/hooks/queries/workspace-file-folders.ts b/apps/sim/hooks/queries/workspace-file-folders.ts index bad1c52c0ee..7eac40ac8c2 100644 --- a/apps/sim/hooks/queries/workspace-file-folders.ts +++ b/apps/sim/hooks/queries/workspace-file-folders.ts @@ -1,152 +1,30 @@ import { toast } from '@sim/emcn' import { toError } from '@sim/utils/errors' -import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { useMutation, useQueryClient } from '@tanstack/react-query' import { requestJson } from '@/lib/api/client/request' import { bulkArchiveWorkspaceFileItemsContract, - createWorkspaceFileFolderContract, - listWorkspaceFileFoldersContract, moveWorkspaceFileItemsContract, - restoreWorkspaceFileFolderContract, - updateWorkspaceFileFolderContract, - type WorkspaceFileFolderApi, } from '@/lib/api/contracts/workspace-file-folders' +import { folderKeys } from '@/hooks/queries/utils/folder-keys' import { workspaceFilesKeys } from '@/hooks/queries/workspace-files' -type WorkspaceFileFolderScope = 'active' | 'archived' | 'all' -export type { WorkspaceFileFolderApi } - -export const WORKSPACE_FILE_FOLDERS_STALE_TIME = 30 * 1000 - -export const workspaceFileFolderKeys = { - all: ['workspaceFileFolders'] as const, - lists: () => [...workspaceFileFolderKeys.all, 'list'] as const, - workspaceLists: (workspaceId: string) => - [...workspaceFileFolderKeys.lists(), workspaceId] as const, - list: (workspaceId: string, scope: WorkspaceFileFolderScope = 'active') => - [...workspaceFileFolderKeys.workspaceLists(workspaceId), scope] as const, -} - -async function fetchWorkspaceFileFolders( - workspaceId: string, - scope: WorkspaceFileFolderScope, - signal?: AbortSignal -): Promise { - const data = await requestJson(listWorkspaceFileFoldersContract, { - params: { id: workspaceId }, - query: { scope }, - signal, - }) - return data.folders -} - +/** + * Mixed-item (file + folder) mutations that address multiple ids at once. + * Folder CRUD (create/rename/delete/restore) lives in `@/hooks/queries/folders` + * (generic `resourceType: 'file'` hooks against `/api/folders/**`). + */ function invalidateWorkspaceFileBrowsers( queryClient: ReturnType, workspaceId: string ) { - queryClient.invalidateQueries({ queryKey: workspaceFileFolderKeys.workspaceLists(workspaceId) }) + queryClient.invalidateQueries({ + queryKey: folderKeys.workspaceResourceLists(workspaceId, 'file'), + }) queryClient.invalidateQueries({ queryKey: workspaceFilesKeys.workspaceLists(workspaceId) }) queryClient.invalidateQueries({ queryKey: workspaceFilesKeys.storageInfo() }) } -export function useWorkspaceFileFolders( - workspaceId: string, - scope: WorkspaceFileFolderScope = 'active' -) { - return useQuery({ - queryKey: workspaceFileFolderKeys.list(workspaceId, scope), - queryFn: ({ signal }) => fetchWorkspaceFileFolders(workspaceId, scope, signal), - enabled: Boolean(workspaceId), - staleTime: WORKSPACE_FILE_FOLDERS_STALE_TIME, - placeholderData: keepPreviousData, - }) -} - -export function useCreateWorkspaceFileFolder() { - const queryClient = useQueryClient() - return useMutation({ - mutationFn: async (variables: { - workspaceId: string - name: string - parentId?: string | null - }) => { - const data = await requestJson(createWorkspaceFileFolderContract, { - params: { id: variables.workspaceId }, - body: { name: variables.name, parentId: variables.parentId }, - }) - return data.folder - }, - onSettled: (_data, _error, variables) => { - invalidateWorkspaceFileBrowsers(queryClient, variables.workspaceId) - }, - }) -} - -export function useUpdateWorkspaceFileFolder() { - const queryClient = useQueryClient() - return useMutation({ - mutationFn: async (variables: { - workspaceId: string - folderId: string - updates: { name?: string; parentId?: string | null; sortOrder?: number } - }) => { - const data = await requestJson(updateWorkspaceFileFolderContract, { - params: { id: variables.workspaceId, folderId: variables.folderId }, - body: variables.updates, - }) - return data.folder - }, - onMutate: async ({ workspaceId, folderId, updates }) => { - await queryClient.cancelQueries({ - queryKey: workspaceFileFolderKeys.workspaceLists(workspaceId), - }) - const previous = queryClient.getQueryData( - workspaceFileFolderKeys.list(workspaceId, 'active') - ) - if (previous) { - const target = previous.find((f) => f.id === folderId) - const oldPath = target?.path - const newPath = - updates.name !== undefined && oldPath !== undefined - ? [...oldPath.split('/').slice(0, -1), updates.name].filter(Boolean).join('/') - : oldPath - - queryClient.setQueryData( - workspaceFileFolderKeys.list(workspaceId, 'active'), - previous.map((f) => { - if (f.id === folderId) { - return { ...f, ...updates, ...(newPath !== undefined ? { path: newPath } : {}) } - } - // Recompute descendant paths so breadcrumbs stay correct during the optimistic window - if ( - updates.name !== undefined && - oldPath !== undefined && - newPath !== undefined && - f.path?.startsWith(`${oldPath}/`) - ) { - return { ...f, path: `${newPath}${f.path.slice(oldPath.length)}` } - } - return f - }) - ) - } - return { previous } - }, - onError: (err, variables, context) => { - if (context?.previous) { - queryClient.setQueryData( - workspaceFileFolderKeys.list(variables.workspaceId, 'active'), - context.previous - ) - } - toast.error(toError(err).message) - }, - onSettled: (_data, _error, variables) => { - invalidateWorkspaceFileBrowsers(queryClient, variables.workspaceId) - }, - }) -} - export function useMoveWorkspaceFileItems() { const queryClient = useQueryClient() return useMutation({ @@ -205,22 +83,3 @@ export function useBulkArchiveWorkspaceFileItems() { }, }) } - -export function useRestoreWorkspaceFileFolder() { - const queryClient = useQueryClient() - return useMutation({ - mutationFn: async (variables: { workspaceId: string; folderId: string }) => - requestJson(restoreWorkspaceFileFolderContract, { - params: { id: variables.workspaceId, folderId: variables.folderId }, - }), - onSuccess: () => { - toast.success('Folder restored') - }, - onError: (err) => { - toast.error(toError(err).message) - }, - onSettled: (_data, _error, variables) => { - invalidateWorkspaceFileBrowsers(queryClient, variables.workspaceId) - }, - }) -} diff --git a/apps/sim/hooks/queries/workspace-files.ts b/apps/sim/hooks/queries/workspace-files.ts index edd65bb3984..b4ca9c99620 100644 --- a/apps/sim/hooks/queries/workspace-files.ts +++ b/apps/sim/hooks/queries/workspace-files.ts @@ -481,18 +481,19 @@ interface RenameFileParams { workspaceId: string fileId: string name: string + locked?: boolean } export function useRenameWorkspaceFile() { const queryClient = useQueryClient() return useMutation({ - mutationFn: async ({ workspaceId, fileId, name }: RenameFileParams) => + mutationFn: async ({ workspaceId, fileId, name, locked }: RenameFileParams) => requestJson(renameWorkspaceFileContract, { params: { id: workspaceId, fileId }, - body: { name }, + body: { name, locked }, }), - onMutate: async ({ workspaceId, fileId, name }) => { + onMutate: async ({ workspaceId, fileId, name, locked }) => { await queryClient.cancelQueries({ queryKey: workspaceFilesKeys.workspaceLists(workspaceId) }) const previous = queryClient.getQueryData( workspaceFilesKeys.list(workspaceId, 'active') @@ -500,7 +501,9 @@ export function useRenameWorkspaceFile() { if (previous) { queryClient.setQueryData( workspaceFilesKeys.list(workspaceId, 'active'), - previous.map((f) => (f.id === fileId ? { ...f, name } : f)) + previous.map((f) => + f.id === fileId ? { ...f, name, ...(locked !== undefined ? { locked } : {}) } : f + ) ) } return { previous } diff --git a/apps/sim/hooks/selectors/providers/sim/selectors.ts b/apps/sim/hooks/selectors/providers/sim/selectors.ts index 1c7784cd982..34295c9dfc0 100644 --- a/apps/sim/hooks/selectors/providers/sim/selectors.ts +++ b/apps/sim/hooks/selectors/providers/sim/selectors.ts @@ -13,7 +13,7 @@ import type { SelectorOption, SelectorQueryArgs, } from '@/hooks/selectors/types' -import type { WorkflowFolder } from '@/stores/folders/types' +import type { Folder } from '@/stores/folders/types' import type { WorkflowMetadata } from '@/stores/workflows/registry/types' /** @@ -24,7 +24,7 @@ import type { WorkflowMetadata } from '@/stores/workflows/registry/types' function buildDisambiguatedLabel( workflow: WorkflowMetadata, duplicateNames: Set, - folders: Record + folders: Record ): string { const baseLabel = workflow.name || `Workflow ${workflow.id.slice(0, 8)}` if (!duplicateNames.has(baseLabel)) return baseLabel diff --git a/apps/sim/hooks/use-folder-breadcrumbs.ts b/apps/sim/hooks/use-folder-breadcrumbs.ts new file mode 100644 index 00000000000..b0da98c15d5 --- /dev/null +++ b/apps/sim/hooks/use-folder-breadcrumbs.ts @@ -0,0 +1,115 @@ +import { useMemo, useRef } from 'react' +import type { BreadcrumbItem } from '@/app/workspace/[workspaceId]/components' + +/** Minimal shape required to walk a `parentId` chain and render a crumb label. */ +export interface BreadcrumbFolder { + id: string + name: string + parentId: string | null +} + +/** The subset of `useInlineRename`'s return value the breadcrumb trail needs. */ +export interface BreadcrumbRenameState { + editingId: string | null + editValue: string + setEditValue: (value: string) => void + submitRename: () => void | Promise + cancelRename: () => void + startRename: (id: string, currentName: string) => void +} + +interface UseFolderBreadcrumbsProps { + folderById: Map + currentFolderId: string | null + rootLabel: string + onNavigateRoot: () => void + onNavigateFolder: (folderId: string) => void + breadcrumbRename: BreadcrumbRenameState + canEdit: boolean + /** Shows the "Rename" dropdown item while permissions are still resolving, matching existing pages' loading-optimistic UX. */ + canEditLoading?: boolean +} + +/** + * Builds the root-to-current breadcrumb trail for a folder-scoped resource + * list (files/knowledge/tables), including the inline-rename affordance on + * the current folder's crumb. Shared by every page that renders a folder + * breadcrumb so the walk-and-render logic (and the current-folder rename + * wiring) exists exactly once. + * + * `breadcrumbRename` is captured through a ref updated synchronously during + * render — not listed field-by-field in the memo's dependency array — so the + * trail always reads the freshest rename state without forcing callers to + * memoize every function `useInlineRename` returns. + */ +export function useFolderBreadcrumbs({ + folderById, + currentFolderId, + rootLabel, + onNavigateRoot, + onNavigateFolder, + breadcrumbRename, + canEdit, + canEditLoading = false, +}: UseFolderBreadcrumbsProps): BreadcrumbItem[] { + const renameRef = useRef(breadcrumbRename) + renameRef.current = breadcrumbRename + + return useMemo(() => { + const breadcrumbs: BreadcrumbItem[] = [{ label: rootLabel, onClick: onNavigateRoot }] + if (!currentFolderId) return breadcrumbs + + // Walk the parentId chain from the current folder up to the root, then + // reverse so the trail renders root-first. + const chain: TFolder[] = [] + const visited = new Set() + let cursor: string | null = currentFolderId + while (cursor && !visited.has(cursor)) { + visited.add(cursor) + const folder = folderById.get(cursor) + if (!folder) break + chain.push(folder) + cursor = folder.parentId + } + chain.reverse() + + for (const folder of chain) { + const isCurrentFolder = folder.id === currentFolderId + breadcrumbs.push({ + label: folder.name, + onClick: isCurrentFolder ? undefined : () => onNavigateFolder(folder.id), + editing: + isCurrentFolder && renameRef.current.editingId === folder.id + ? { + isEditing: true, + value: renameRef.current.editValue, + onChange: renameRef.current.setEditValue, + onSubmit: renameRef.current.submitRename, + onCancel: renameRef.current.cancelRename, + } + : undefined, + dropdownItems: + isCurrentFolder && (canEdit || canEditLoading) + ? [ + { + label: 'Rename', + disabled: !canEdit, + onClick: () => renameRef.current.startRename(folder.id, folder.name), + }, + ] + : undefined, + }) + } + return breadcrumbs + }, [ + currentFolderId, + folderById, + rootLabel, + onNavigateRoot, + onNavigateFolder, + canEdit, + canEditLoading, + breadcrumbRename.editingId, + breadcrumbRename.editValue, + ]) +} diff --git a/apps/sim/hooks/use-folder-create-with-dedup.ts b/apps/sim/hooks/use-folder-create-with-dedup.ts new file mode 100644 index 00000000000..7da68e29265 --- /dev/null +++ b/apps/sim/hooks/use-folder-create-with-dedup.ts @@ -0,0 +1,72 @@ +import { useCallback } from 'react' +import { toast } from '@sim/emcn' +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import type { FolderResourceType } from '@/lib/api/contracts/folders' +import type { useCreateFolder } from '@/hooks/queries/folders' +import type { Folder } from '@/stores/folders/types' + +const logger = createLogger('useFolderCreateWithDedup') + +/** Minimal shape required to dedupe a new folder's name against its siblings. */ +export interface DedupFolder { + name: string + parentId: string | null +} + +interface UseFolderCreateWithDedupProps { + workspaceId: string | undefined + resourceType: FolderResourceType + folders: TFolder[] + currentFolderId: string | null + createFolder: ReturnType + /** Runs after a successful create — e.g. kicking off inline rename on the new row. */ + onCreated: (folder: Folder) => void + /** Base name before the "(N)" dedup suffix. Defaults to `'New folder'`. */ + baseName?: string +} + +/** + * Creates a new folder under `currentFolderId`, deduping its name against + * existing siblings ("New folder", "New folder (1)", "New folder (2)", …). + * Shared by every folder-scoped resource page (files/knowledge/tables) so + * the dedup-numbering loop exists exactly once. + */ +export function useFolderCreateWithDedup({ + workspaceId, + resourceType, + folders, + currentFolderId, + createFolder, + onCreated, + baseName = 'New folder', +}: UseFolderCreateWithDedupProps): () => Promise { + return useCallback(async () => { + if (!workspaceId) return + const existingNames = new Set( + folders + .filter((folder) => (folder.parentId ?? null) === currentFolderId) + .map((folder) => folder.name) + ) + let name = baseName + let counter = 1 + while (existingNames.has(name)) { + name = `${baseName} (${counter})` + counter++ + } + + try { + const folder = await createFolder.mutateAsync({ + workspaceId, + resourceType, + name, + parentId: currentFolderId ?? undefined, + }) + onCreated(folder) + } catch (error) { + logger.error('Failed to create folder:', error) + toast.error(toError(error).message) + } + // eslint-disable-next-line react-hooks/exhaustive-deps -- createFolder.mutateAsync is stable + }, [workspaceId, folders, currentFolderId, resourceType, onCreated, baseName]) +} diff --git a/apps/sim/lib/api/contracts/folders.ts b/apps/sim/lib/api/contracts/folders.ts index fc2f2d4abc5..d48bdef0594 100644 --- a/apps/sim/lib/api/contracts/folders.ts +++ b/apps/sim/lib/api/contracts/folders.ts @@ -1,37 +1,41 @@ import { z } from 'zod' import { defineRouteContract } from '@/lib/api/contracts/types' +/** Mirrors `folderResourceTypeEnum` in `packages/db/schema.ts`. */ +export const folderResourceTypeSchema = z.enum(['workflow', 'file', 'knowledge_base', 'table']) +export type FolderResourceType = z.output + export const folderScopeSchema = z.enum(['active', 'archived']) export const folderSchema = z.object({ id: z.string(), + resourceType: folderResourceTypeSchema, name: z.string(), userId: z.string(), workspaceId: z.string(), parentId: z.string().nullable(), - color: z.string().nullable(), - isExpanded: z.boolean(), locked: z.boolean(), sortOrder: z.number(), createdAt: z.string(), updatedAt: z.string(), - archivedAt: z.string().nullable(), + deletedAt: z.string().nullable(), }) export type FolderApi = z.output export const listFoldersQuerySchema = z.object({ workspaceId: z.string({ error: 'Workspace ID is required' }).min(1, 'Workspace ID is required'), + resourceType: folderResourceTypeSchema, scope: folderScopeSchema.default('active'), }) export const createFolderBodySchema = z.object({ id: z.string().uuid().optional(), + resourceType: folderResourceTypeSchema, name: z.string().min(1, 'Name is required'), workspaceId: z.string().min(1, 'Workspace ID is required'), /** Mirrors `updateFolderBodySchema.parentId` so explicit `null` (root folder) is accepted on create. */ parentId: z.string().nullable().optional(), - color: z.string().optional(), sortOrder: z.number().int().optional(), }) @@ -41,8 +45,6 @@ export const folderIdParamsSchema = z.object({ export const updateFolderBodySchema = z.object({ name: z.string().optional(), - color: z.string().optional(), - isExpanded: z.boolean().optional(), locked: z.boolean().optional(), parentId: z.string().nullable().optional(), sortOrder: z.number().int().min(0).optional(), @@ -52,23 +54,34 @@ export const restoreFolderBodySchema = z.object({ workspaceId: z.string({ error: 'Workspace ID is required' }).min(1, 'Workspace ID is required'), }) +/** Per-resourceType cascade counts from a folder delete/restore; only the relevant fields are populated. */ +export const folderCascadeCountsSchema = z.object({ + folders: z.number(), + workflows: z.number().optional(), + files: z.number().optional(), + knowledgeBases: z.number().optional(), + tables: z.number().optional(), +}) + export const duplicateFolderBodySchema = z.object({ name: z.string().min(1, 'Name is required'), workspaceId: z.string().optional(), parentId: z.string().nullable().optional(), - color: z.string().optional(), newId: z.string().uuid().optional(), }) export const reorderFoldersBodySchema = z.object({ workspaceId: z.string(), - updates: z.array( - z.object({ - id: z.string(), - sortOrder: z.number().int().min(0), - parentId: z.string().nullable().optional(), - }) - ), + updates: z + .array( + z.object({ + id: z.string(), + sortOrder: z.number().int().min(0), + parentId: z.string().nullable().optional(), + }) + ) + .min(1, 'At least one folder must be provided') + .max(1000, 'At most 1000 folders can be reordered at once'), }) export const listFoldersContract = defineRouteContract({ @@ -116,7 +129,7 @@ export const deleteFolderContract = defineRouteContract({ mode: 'json', schema: z.object({ success: z.literal(true), - deletedItems: z.unknown(), + deletedItems: folderCascadeCountsSchema.optional(), }), }, }) @@ -130,7 +143,7 @@ export const restoreFolderContract = defineRouteContract({ mode: 'json', schema: z.object({ success: z.literal(true), - restoredItems: z.unknown(), + restoredItems: folderCascadeCountsSchema.optional(), }), }, }) diff --git a/apps/sim/lib/api/contracts/index.ts b/apps/sim/lib/api/contracts/index.ts index ef339965c5b..87aae1367e5 100644 --- a/apps/sim/lib/api/contracts/index.ts +++ b/apps/sim/lib/api/contracts/index.ts @@ -15,6 +15,7 @@ export * from './hotspots' export * from './inbox' export * from './media' export * from './permission-groups' +export * from './pinned-items' export * from './primitives' export * from './selectors' export * from './skills' diff --git a/apps/sim/lib/api/contracts/knowledge/base.ts b/apps/sim/lib/api/contracts/knowledge/base.ts index cf2c8f67b66..5852cb51d6b 100644 --- a/apps/sim/lib/api/contracts/knowledge/base.ts +++ b/apps/sim/lib/api/contracts/knowledge/base.ts @@ -60,6 +60,8 @@ export const createKnowledgeBaseBodySchema = z.object({ ) .optional(), workspaceId: z.string().min(1, 'Workspace ID is required'), + /** Folder to create the knowledge base in. Omit or `null` for the workspace root. */ + folderId: z.string().min(1).nullable().optional(), embeddingModel: z.literal('text-embedding-3-small').default('text-embedding-3-small'), embeddingDimension: z.literal(1536).default(1536), chunkingConfig: chunkingConfigSchema.default({ @@ -73,6 +75,7 @@ export const updateKnowledgeBaseBodySchema = createKnowledgeBaseBodySchema .pick({ name: true, description: true, + folderId: true, }) .partial() .extend({ @@ -80,6 +83,7 @@ export const updateKnowledgeBaseBodySchema = createKnowledgeBaseBodySchema workspaceId: z.string().nullable().optional(), embeddingModel: z.literal('text-embedding-3-small').optional(), embeddingDimension: z.literal(1536).optional(), + locked: z.boolean().optional(), }) const knowledgeChunkingConfigSchema = z @@ -106,6 +110,8 @@ export const knowledgeBaseDataSchema = z updatedAt: wireDateSchema, deletedAt: nullableWireDateSchema, workspaceId: z.string().nullable(), + folderId: z.string().nullable(), + locked: z.boolean(), docCount: z.number().optional(), connectorTypes: z.array(z.string()).optional(), }) diff --git a/apps/sim/lib/api/contracts/pinned-items.ts b/apps/sim/lib/api/contracts/pinned-items.ts new file mode 100644 index 00000000000..b0eff7c2ba0 --- /dev/null +++ b/apps/sim/lib/api/contracts/pinned-items.ts @@ -0,0 +1,79 @@ +import { z } from 'zod' +import { defineRouteContract } from '@/lib/api/contracts/types' + +/** + * Mirrors `pinnedItem.resourceType` in `packages/db/schema.ts`, which is deliberately + * plain `text` on that table (not a `pgEnum` like `folder.resourceType`) — pinning + * accepts `'folder'` itself in addition to the four folder-bearing resource types. + */ +export const pinnedResourceTypeSchema = z.enum([ + 'folder', + 'workflow', + 'file', + 'knowledge_base', + 'table', +]) +export type PinnedResourceType = z.output + +export const pinnedItemSchema = z.object({ + id: z.string(), + userId: z.string(), + workspaceId: z.string(), + resourceType: pinnedResourceTypeSchema, + resourceId: z.string(), + pinnedAt: z.string(), +}) + +export type PinnedItemApi = z.output + +export const listPinnedItemsQuerySchema = z.object({ + workspaceId: z.string({ error: 'Workspace ID is required' }).min(1, 'Workspace ID is required'), + resourceType: pinnedResourceTypeSchema.optional(), +}) + +export const createPinnedItemBodySchema = z.object({ + workspaceId: z.string({ error: 'Workspace ID is required' }).min(1, 'Workspace ID is required'), + resourceType: pinnedResourceTypeSchema, + resourceId: z.string().min(1, 'Resource ID is required'), +}) + +export const pinnedItemResourceParamsSchema = z.object({ + resourceType: pinnedResourceTypeSchema, + resourceId: z.string().min(1), +}) + +export const listPinnedItemsContract = defineRouteContract({ + method: 'GET', + path: '/api/pinned-items', + query: listPinnedItemsQuerySchema, + response: { + mode: 'json', + schema: z.object({ + pinnedItems: z.array(pinnedItemSchema), + }), + }, +}) + +export const createPinnedItemContract = defineRouteContract({ + method: 'POST', + path: '/api/pinned-items', + body: createPinnedItemBodySchema, + response: { + mode: 'json', + schema: z.object({ + pinnedItem: pinnedItemSchema, + }), + }, +}) + +export const deletePinnedItemContract = defineRouteContract({ + method: 'DELETE', + path: '/api/pinned-items/[resourceType]/[resourceId]', + params: pinnedItemResourceParamsSchema, + response: { + mode: 'json', + schema: z.object({ + success: z.literal(true), + }), + }, +}) diff --git a/apps/sim/lib/api/contracts/tables.ts b/apps/sim/lib/api/contracts/tables.ts index c357cc585e8..b2acf0be02f 100644 --- a/apps/sim/lib/api/contracts/tables.ts +++ b/apps/sim/lib/api/contracts/tables.ts @@ -103,11 +103,16 @@ export const createTableBodySchema = z.object({ }), workspaceId: z.string().min(1, 'Workspace ID is required'), initialRowCount: z.number().int().min(0).max(100).optional(), + /** Folder to create the table in. Omit or `null` for the workspace root. */ + folderId: z.string().min(1).nullable().optional(), }) export const renameTableBodySchema = z.object({ workspaceId: z.string().min(1, 'Workspace ID is required'), name: tableNameSchema, + /** Move the table to this folder. Omit to leave unchanged; `null` moves it to the workspace root. */ + folderId: z.string().min(1).nullable().optional(), + locked: z.boolean().optional(), }) export const createTableColumnBodySchema = z.object({ diff --git a/apps/sim/lib/api/contracts/workspace-file-folders.ts b/apps/sim/lib/api/contracts/workspace-file-folders.ts index 11fad5227ab..9924cf88d66 100644 --- a/apps/sim/lib/api/contracts/workspace-file-folders.ts +++ b/apps/sim/lib/api/contracts/workspace-file-folders.ts @@ -1,63 +1,23 @@ import { z } from 'zod' import { defineRouteContract } from '@/lib/api/contracts/types' -export const workspaceFileFolderScopeSchema = z.enum(['active', 'archived', 'all']) - +/** + * Mixed-item (file + folder) operations that address multiple ids at once. + * Folder CRUD itself lives in `@/lib/api/contracts/folders` (generic + * `resourceType`-parameterized `/api/folders/**` routes). + */ export const workspaceFileFoldersParamsSchema = z.object({ id: z.string({ error: 'Workspace ID is required' }).min(1, 'Workspace ID is required'), }) -export const workspaceFileFolderParamsSchema = workspaceFileFoldersParamsSchema.extend({ - folderId: z.string({ error: 'Folder ID is required' }).min(1, 'Folder ID is required'), -}) - -export const listWorkspaceFileFoldersQuerySchema = z.object({ - scope: workspaceFileFolderScopeSchema.default('active'), -}) - -export const workspaceFileFolderSchema = z.object({ - id: z.string(), - workspaceId: z.string(), - userId: z.string(), - name: z.string(), - parentId: z.string().nullable(), - path: z.string(), - sortOrder: z.number(), - deletedAt: z.coerce.date().nullable(), - createdAt: z.coerce.date(), - updatedAt: z.coerce.date(), -}) - -export type WorkspaceFileFolderApi = z.output - const workspaceFileFoldersSuccessSchema = z.object({ success: z.boolean(), }) -const workspaceFileFolderNameSchema = z - .string({ error: 'Name is required' }) - .trim() - .min(1, 'Name is required') - .refine( - (name) => name !== '.' && name !== '..' && !name.includes('/') && !name.includes('\\'), - 'Name cannot contain path separators or dot segments' - ) - -export const createWorkspaceFileFolderBodySchema = z.object({ - name: workspaceFileFolderNameSchema, - parentId: z.string().nullable().optional(), -}) - -export const updateWorkspaceFileFolderBodySchema = z.object({ - name: workspaceFileFolderNameSchema.optional(), - parentId: z.string().nullable().optional(), - sortOrder: z.number().int().optional(), -}) - export const moveWorkspaceFileItemsBodySchema = z .object({ - fileIds: z.array(z.string()).default([]), - folderIds: z.array(z.string()).default([]), + fileIds: z.array(z.string()).max(100, 'At most 100 files can be moved at once').default([]), + folderIds: z.array(z.string()).max(100, 'At most 100 folders can be moved at once').default([]), targetFolderId: z.string().nullable().optional(), }) .refine((body) => body.fileIds.length > 0 || body.folderIds.length > 0, { @@ -66,8 +26,11 @@ export const moveWorkspaceFileItemsBodySchema = z export const bulkArchiveWorkspaceFileItemsBodySchema = z .object({ - fileIds: z.array(z.string()).default([]), - folderIds: z.array(z.string()).default([]), + fileIds: z.array(z.string()).max(100, 'At most 100 files can be deleted at once').default([]), + folderIds: z + .array(z.string()) + .max(100, 'At most 100 folders can be deleted at once') + .default([]), }) .refine((body) => body.fileIds.length > 0 || body.folderIds.length > 0, { message: 'At least one file or folder must be selected', @@ -90,60 +53,6 @@ export const downloadWorkspaceFileItemsQuerySchema = z.object({ folderIds: queryIdListSchema, }) -export const listWorkspaceFileFoldersContract = defineRouteContract({ - method: 'GET', - path: '/api/workspaces/[id]/files/folders', - params: workspaceFileFoldersParamsSchema, - query: listWorkspaceFileFoldersQuerySchema, - response: { - mode: 'json', - schema: workspaceFileFoldersSuccessSchema.extend({ - folders: z.array(workspaceFileFolderSchema), - }), - }, -}) - -export const createWorkspaceFileFolderContract = defineRouteContract({ - method: 'POST', - path: '/api/workspaces/[id]/files/folders', - params: workspaceFileFoldersParamsSchema, - body: createWorkspaceFileFolderBodySchema, - response: { - mode: 'json', - schema: workspaceFileFoldersSuccessSchema.extend({ - folder: workspaceFileFolderSchema, - }), - }, -}) - -export const updateWorkspaceFileFolderContract = defineRouteContract({ - method: 'PATCH', - path: '/api/workspaces/[id]/files/folders/[folderId]', - params: workspaceFileFolderParamsSchema, - body: updateWorkspaceFileFolderBodySchema, - response: { - mode: 'json', - schema: workspaceFileFoldersSuccessSchema.extend({ - folder: workspaceFileFolderSchema, - }), - }, -}) - -export const deleteWorkspaceFileFolderContract = defineRouteContract({ - method: 'DELETE', - path: '/api/workspaces/[id]/files/folders/[folderId]', - params: workspaceFileFolderParamsSchema, - response: { - mode: 'json', - schema: workspaceFileFoldersSuccessSchema.extend({ - deletedItems: z.object({ - folders: z.number().int(), - files: z.number().int(), - }), - }), - }, -}) - export const moveWorkspaceFileItemsContract = defineRouteContract({ method: 'POST', path: '/api/workspaces/[id]/files/move', @@ -185,19 +94,3 @@ export const downloadWorkspaceFileItemsContract = defineRouteContract({ mode: 'binary', }, }) - -export const restoreWorkspaceFileFolderContract = defineRouteContract({ - method: 'POST', - path: '/api/workspaces/[id]/files/folders/[folderId]/restore', - params: workspaceFileFolderParamsSchema, - response: { - mode: 'json', - schema: workspaceFileFoldersSuccessSchema.extend({ - folder: workspaceFileFolderSchema, - restoredItems: z.object({ - folders: z.number().int(), - files: z.number().int(), - }), - }), - }, -}) diff --git a/apps/sim/lib/api/contracts/workspace-files.ts b/apps/sim/lib/api/contracts/workspace-files.ts index b47b5710528..5ed09a900c6 100644 --- a/apps/sim/lib/api/contracts/workspace-files.ts +++ b/apps/sim/lib/api/contracts/workspace-files.ts @@ -43,6 +43,7 @@ const workspaceFileNameSchema = z export const renameWorkspaceFileBodySchema = z.object({ name: workspaceFileNameSchema, + locked: z.boolean().optional(), }) export const updateWorkspaceFileContentBodySchema = z.object({ @@ -62,6 +63,7 @@ export const workspaceFileRecordSchema = z.object({ uploadedBy: z.string(), folderId: z.string().nullable(), folderPath: z.string().nullable().optional(), + locked: z.boolean(), deletedAt: z.coerce.date().nullable().optional(), uploadedAt: z.coerce.date(), updatedAt: z.coerce.date(), diff --git a/apps/sim/lib/cleanup/batch-delete.ts b/apps/sim/lib/cleanup/batch-delete.ts index d523800cd59..769e8a61220 100644 --- a/apps/sim/lib/cleanup/batch-delete.ts +++ b/apps/sim/lib/cleanup/batch-delete.ts @@ -1,6 +1,6 @@ import { db } from '@sim/db' import { createLogger } from '@sim/logger' -import { and, inArray, isNotNull, lt, sql } from 'drizzle-orm' +import { and, inArray, isNotNull, lt, type SQL, sql } from 'drizzle-orm' import type { PgColumn, PgTable } from 'drizzle-orm/pg-core' const logger = createLogger('BatchDelete') @@ -186,6 +186,13 @@ export interface BatchDeleteOptions { tableName: string /** When true, also requires `timestampCol IS NOT NULL` (soft-delete semantics). */ requireTimestampNotNull?: boolean + /** + * Extra predicate ANDed into the SELECT — e.g. a discriminator column on a + * polymorphic table (`folder.resourceType = 'workflow'`) so cleanup only + * targets rows belonging to this logical table, not sibling resource types + * sharing the same physical table. + */ + extraPredicate?: SQL batchSize?: number maxBatches?: number workspaceChunkSize?: number @@ -204,6 +211,7 @@ export async function batchDeleteByWorkspaceAndTimestamp({ retentionDate, tableName, requireTimestampNotNull = false, + extraPredicate, ...rest }: BatchDeleteOptions): Promise { return chunkedBatchDelete({ @@ -213,6 +221,7 @@ export async function batchDeleteByWorkspaceAndTimestamp({ selectChunk: (chunkIds, limit) => { const predicates = [inArray(workspaceIdCol, chunkIds), lt(timestampCol, retentionDate)] if (requireTimestampNotNull) predicates.push(isNotNull(timestampCol)) + if (extraPredicate) predicates.push(extraPredicate) return db .select({ id: sql`id` }) .from(tableDef) diff --git a/apps/sim/lib/copilot/chat/workspace-context.test.ts b/apps/sim/lib/copilot/chat/workspace-context.test.ts index 2f8caa114f7..320abe48073 100644 --- a/apps/sim/lib/copilot/chat/workspace-context.test.ts +++ b/apps/sim/lib/copilot/chat/workspace-context.test.ts @@ -12,7 +12,7 @@ vi.mock('@sim/db/schema', () => ({ userTableDefinitions: {}, userTableRows: {}, workflow: {}, - workflowFolder: {}, + folder: {}, workflowSchedule: {}, })) diff --git a/apps/sim/lib/copilot/chat/workspace-context.ts b/apps/sim/lib/copilot/chat/workspace-context.ts index 7558b06f56c..bf027a86f76 100644 --- a/apps/sim/lib/copilot/chat/workspace-context.ts +++ b/apps/sim/lib/copilot/chat/workspace-context.ts @@ -5,7 +5,7 @@ import { mcpServers, userTableDefinitions, workflow, - workflowFolder, + folder as workflowFolder, workflowSchedule, } from '@sim/db/schema' import { createLogger } from '@sim/logger' @@ -377,7 +377,13 @@ async function buildWorkspaceMdData( parentId: workflowFolder.parentId, }) .from(workflowFolder) - .where(and(eq(workflowFolder.workspaceId, workspaceId), isNull(workflowFolder.archivedAt))), + .where( + and( + eq(workflowFolder.workspaceId, workspaceId), + eq(workflowFolder.resourceType, 'workflow'), + isNull(workflowFolder.deletedAt) + ) + ), db .select({ diff --git a/apps/sim/lib/copilot/tools/handlers/materialize-file.ts b/apps/sim/lib/copilot/tools/handlers/materialize-file.ts index a514f6f735c..dcac281a92a 100644 --- a/apps/sim/lib/copilot/tools/handlers/materialize-file.ts +++ b/apps/sim/lib/copilot/tools/handlers/materialize-file.ts @@ -28,6 +28,7 @@ function toFileRecord(row: typeof workspaceFiles.$inferSelect) { size: row.size, type: row.contentType, uploadedBy: row.userId, + locked: row.locked, deletedAt: row.deletedAt, uploadedAt: row.uploadedAt, updatedAt: row.updatedAt, diff --git a/apps/sim/lib/copilot/tools/handlers/restore-resource.ts b/apps/sim/lib/copilot/tools/handlers/restore-resource.ts index 816efb1660f..a1ebabac3fb 100644 --- a/apps/sim/lib/copilot/tools/handlers/restore-resource.ts +++ b/apps/sim/lib/copilot/tools/handlers/restore-resource.ts @@ -1,7 +1,16 @@ import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' import { performRestoreResource, type RestorableResourceType } from '@/lib/resources/orchestration' -const VALID_TYPES = new Set(['workflow', 'table', 'file', 'knowledgebase', 'folder', 'file_folder']) +const VALID_TYPES = new Set([ + 'workflow', + 'table', + 'file', + 'knowledgebase', + 'folder', + 'file_folder', + 'table_folder', + 'knowledge_base_folder', +]) export async function executeRestoreResource( rawParams: Record, diff --git a/apps/sim/lib/copilot/tools/handlers/upload-file-reader.ts b/apps/sim/lib/copilot/tools/handlers/upload-file-reader.ts index 0e914229c8a..3ec7cc3be3d 100644 --- a/apps/sim/lib/copilot/tools/handlers/upload-file-reader.ts +++ b/apps/sim/lib/copilot/tools/handlers/upload-file-reader.ts @@ -54,6 +54,7 @@ function toWorkspaceFileRecord(row: typeof workspaceFiles.$inferSelect): Workspa size: row.size, type: row.contentType, uploadedBy: row.userId, + locked: row.locked, deletedAt: row.deletedAt, uploadedAt: row.uploadedAt, updatedAt: row.updatedAt, diff --git a/apps/sim/lib/copilot/vfs/workflow-alias-backing.ts b/apps/sim/lib/copilot/vfs/workflow-alias-backing.ts index b57ea3a72c8..99ecf7c869d 100644 --- a/apps/sim/lib/copilot/vfs/workflow-alias-backing.ts +++ b/apps/sim/lib/copilot/vfs/workflow-alias-backing.ts @@ -1,5 +1,5 @@ import { db } from '@sim/db' -import { workspaceFileFolder, workspaceFiles } from '@sim/db/schema' +import { folder as workspaceFileFolder, workspaceFiles } from '@sim/db/schema' import { and, eq, inArray, isNull } from 'drizzle-orm' import { WORKFLOW_CHANGELOG_BACKING_FOLDER, @@ -172,6 +172,7 @@ export async function cleanupWorkflowAliasBacking(args: { .where( and( eq(workspaceFileFolder.workspaceId, args.workspaceId), + eq(workspaceFileFolder.resourceType, 'file'), inArray(workspaceFileFolder.id, ownedFolderIds), isNull(workspaceFileFolder.deletedAt) ) diff --git a/apps/sim/lib/copilot/vfs/workflow-alias-resolver.ts b/apps/sim/lib/copilot/vfs/workflow-alias-resolver.ts index 449b201d6ff..ebbe7d1027e 100644 --- a/apps/sim/lib/copilot/vfs/workflow-alias-resolver.ts +++ b/apps/sim/lib/copilot/vfs/workflow-alias-resolver.ts @@ -1,5 +1,5 @@ import { db } from '@sim/db' -import { workflow, workflowFolder } from '@sim/db/schema' +import { folder, workflow } from '@sim/db/schema' import { and, asc, eq, isNull } from 'drizzle-orm' import { buildWorkflowAliasWorkflowEntries, @@ -40,15 +40,19 @@ export async function resolveWorkflowAliasForWorkspace(args: { .orderBy(asc(workflow.sortOrder), asc(workflow.createdAt)), db .select({ - folderId: workflowFolder.id, - folderName: workflowFolder.name, - parentId: workflowFolder.parentId, + folderId: folder.id, + folderName: folder.name, + parentId: folder.parentId, }) - .from(workflowFolder) + .from(folder) .where( - and(eq(workflowFolder.workspaceId, args.workspaceId), isNull(workflowFolder.archivedAt)) + and( + eq(folder.workspaceId, args.workspaceId), + eq(folder.resourceType, 'workflow'), + isNull(folder.deletedAt) + ) ) - .orderBy(asc(workflowFolder.sortOrder), asc(workflowFolder.createdAt)), + .orderBy(asc(folder.sortOrder), asc(folder.createdAt)), ]) return resolveWorkflowAliasPath( canonicalPath, diff --git a/apps/sim/lib/copilot/vfs/workspace-vfs.ts b/apps/sim/lib/copilot/vfs/workspace-vfs.ts index f40def73f7e..488ae690952 100644 --- a/apps/sim/lib/copilot/vfs/workspace-vfs.ts +++ b/apps/sim/lib/copilot/vfs/workspace-vfs.ts @@ -1,15 +1,15 @@ import { trace } from '@opentelemetry/api' import { db } from '@sim/db' import { - chat as chatTable, + chat, copilotChats, document, jobExecutionLogs, knowledgeConnector, - mcpServers as mcpServersTable, + mcpServers, workflowDeploymentVersion, workflowExecutionLogs, - workflowFolder, + folder as workflowFolder, workflowMcpServer, workflowMcpTool, workflowSchedule, @@ -1782,16 +1782,16 @@ export class WorkspaceVFS { const [chatRows, mcpRows, versionRows, allVersionRows] = await Promise.all([ db .select({ - id: chatTable.id, - identifier: chatTable.identifier, - title: chatTable.title, - description: chatTable.description, - authType: chatTable.authType, - customizations: chatTable.customizations, - isActive: chatTable.isActive, + id: chat.id, + identifier: chat.identifier, + title: chat.title, + description: chat.description, + authType: chat.authType, + customizations: chat.customizations, + isActive: chat.isActive, }) - .from(chatTable) - .where(and(eq(chatTable.workflowId, workflowId), isNull(chatTable.archivedAt))), + .from(chat) + .where(and(eq(chat.workflowId, workflowId), isNull(chat.archivedAt))), db .select({ serverId: workflowMcpTool.serverId, @@ -1966,8 +1966,8 @@ export class WorkspaceVFS { try { const servers = await db .select() - .from(mcpServersTable) - .where(and(eq(mcpServersTable.workspaceId, workspaceId), isNull(mcpServersTable.deletedAt))) + .from(mcpServers) + .where(and(eq(mcpServers.workspaceId, workspaceId), isNull(mcpServers.deletedAt))) for (const server of servers) { const safeName = sanitizeName(server.name) @@ -2235,22 +2235,60 @@ export class WorkspaceVFS { archivedFiles, archivedFileFolders, archivedKBs, + archivedTableFolders, + archivedKnowledgeBaseFolders, ] = await Promise.all([ listWorkflows(workspaceId, { scope: 'archived' }), db .select({ id: workflowFolder.id, name: workflowFolder.name, - archivedAt: workflowFolder.archivedAt, + archivedAt: workflowFolder.deletedAt, }) .from(workflowFolder) .where( - and(eq(workflowFolder.workspaceId, workspaceId), isNotNull(workflowFolder.archivedAt)) + and( + eq(workflowFolder.workspaceId, workspaceId), + eq(workflowFolder.resourceType, 'workflow'), + isNotNull(workflowFolder.deletedAt) + ) ), listTables(workspaceId, { scope: 'archived' }), listWorkspaceFiles(workspaceId, { scope: 'archived' }), listWorkspaceFileFolders(workspaceId, { scope: 'archived' }), getKnowledgeBases(userId, workspaceId, 'archived'), + // `table`/`knowledge_base` folders live in the same polymorphic `folder` + // table as workflow folders -- without these, an archived table/KB folder + // is invisible under `recently-deleted/` and the agent has no way to + // discover it in order to restore it. + db + .select({ + id: workflowFolder.id, + name: workflowFolder.name, + archivedAt: workflowFolder.deletedAt, + }) + .from(workflowFolder) + .where( + and( + eq(workflowFolder.workspaceId, workspaceId), + eq(workflowFolder.resourceType, 'table'), + isNotNull(workflowFolder.deletedAt) + ) + ), + db + .select({ + id: workflowFolder.id, + name: workflowFolder.name, + archivedAt: workflowFolder.deletedAt, + }) + .from(workflowFolder) + .where( + and( + eq(workflowFolder.workspaceId, workspaceId), + eq(workflowFolder.resourceType, 'knowledge_base'), + isNotNull(workflowFolder.deletedAt) + ) + ), ]) for (const wf of archivedWorkflows) { @@ -2290,6 +2328,40 @@ export class WorkspaceVFS { ) } + for (const folder of archivedTableFolders) { + const safeName = sanitizeName(folder.name) + this.files.set( + `recently-deleted/table-folders/${safeName}/meta.json`, + JSON.stringify( + { + id: folder.id, + name: folder.name, + archivedAt: folder.archivedAt, + type: 'table_folder', + }, + null, + 2 + ) + ) + } + + for (const folder of archivedKnowledgeBaseFolders) { + const safeName = sanitizeName(folder.name) + this.files.set( + `recently-deleted/knowledgebase-folders/${safeName}/meta.json`, + JSON.stringify( + { + id: folder.id, + name: folder.name, + archivedAt: folder.archivedAt, + type: 'knowledge_base_folder', + }, + null, + 2 + ) + ) + } + for (const folder of archivedFileFolders) { const safePath = folder.path .split('/') diff --git a/apps/sim/lib/folders/orchestration.ts b/apps/sim/lib/folders/orchestration.ts new file mode 100644 index 00000000000..8ee05142bec --- /dev/null +++ b/apps/sim/lib/folders/orchestration.ts @@ -0,0 +1,1281 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' +import { db } from '@sim/db' +import { folder as folderTable, knowledgeBase, userTableDefinitions } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { + assertFolderMutable, + assertFolderMutableUnlessUnlocking, + ResourceLockedError, +} from '@sim/platform-authz/resource-lock' +import { getPostgresErrorCode, toError } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { and, eq, inArray, isNull, min } from 'drizzle-orm' +import type { FolderResourceType } from '@/lib/api/contracts/folders' +import type { DbOrTx } from '@/lib/db/types' +import { + assertFolderParentValid, + checkFolderCircularReference, +} from '@/lib/folders/parent-validation' +import { collectDescendantFolderIds } from '@/lib/folders/subtree' +import { + archiveWorkspaceFileFolderRecursive, + createWorkspaceFileFolder, + restoreWorkspaceFileFolder, + updateWorkspaceFileFolder, + WorkspaceFileFolderConflictError, + type WorkspaceFileFolderRecord, +} from '@/lib/uploads/contexts/workspace' +import { + performCreateFolder as performCreateWorkflowFolder, + performDeleteFolder as performDeleteWorkflowFolder, + performRestoreFolder as performRestoreWorkflowFolder, + performUpdateFolder as performUpdateWorkflowFolder, +} from '@/lib/workflows/orchestration/folder-lifecycle' +import type { OrchestrationErrorCode } from '@/lib/workflows/orchestration/types' + +const logger = createLogger('FolderOrchestration') + +export type Folder = typeof folderTable.$inferSelect + +export interface PerformCreateFolderParams { + resourceType: FolderResourceType + userId: string + workspaceId: string + name: string + id?: string + parentId?: string | null + sortOrder?: number +} + +export interface PerformFolderResult { + success: boolean + error?: string + errorCode?: OrchestrationErrorCode + folder?: Folder +} + +export interface PerformUpdateFolderParams { + resourceType: FolderResourceType + folderId: string + workspaceId: string + userId: string + name?: string + locked?: boolean + parentId?: string | null + sortOrder?: number +} + +export interface PerformDeleteFolderParams { + resourceType: FolderResourceType + folderId: string + workspaceId: string + userId: string + folderName?: string +} + +export interface PerformDeleteFolderResult { + success: boolean + error?: string + errorCode?: OrchestrationErrorCode + deletedItems?: { + folders: number + workflows?: number + files?: number + knowledgeBases?: number + tables?: number + } +} + +export interface PerformRestoreFolderParams { + resourceType: FolderResourceType + folderId: string + workspaceId: string + userId: string + folderName?: string +} + +export interface PerformRestoreFolderResult { + success: boolean + error?: string + errorCode?: OrchestrationErrorCode + restoredItems?: { + folders: number + workflows?: number + files?: number + knowledgeBases?: number + tables?: number + } +} + +export interface PerformReorderFoldersParams { + resourceType: FolderResourceType + workspaceId: string + updates: Array<{ id: string; sortOrder: number; parentId?: string | null }> +} + +/** + * Adapts the `uploads/contexts/workspace` VFS record (which carries a + * server-computed `path` the generic contract doesn't expose) to the + * physical `folder` table shape the generic routes/contracts expect. + * `locked` is a real, enforced field for `file` folders too -- the generic + * lock engine (`@sim/platform-authz/resource-lock`) reads it to cascade + * locks the same way it does for the other three resourceTypes. + */ +function toFileFolder(record: WorkspaceFileFolderRecord): Folder { + return { + id: record.id, + resourceType: 'file', + name: record.name, + userId: record.userId, + workspaceId: record.workspaceId, + parentId: record.parentId, + locked: record.locked, + sortOrder: record.sortOrder, + createdAt: record.createdAt, + updatedAt: record.updatedAt, + deletedAt: record.deletedAt, + } +} + +/** + * `file`-resourceType folder CRUD delegates to `uploads/contexts/workspace` + * (`createWorkspaceFileFolder`/`updateWorkspaceFileFolder`/ + * `archiveWorkspaceFileFolderRecursive`/`restoreWorkspaceFileFolder`) — the + * single source of truth for file-folder writes (advisory locking, name + * conflict detection, recursive archive/restore) — rather than + * reimplementing that logic against the raw `folder` table here. + */ +async function performCreateFileFolder( + params: PerformCreateFolderParams +): Promise { + const parentId = params.parentId || null + const folderId = params.id || generateId() + if (parentId === folderId) { + return { success: false, error: 'Folder cannot be its own parent', errorCode: 'validation' } + } + const parentError = await assertFolderParentValid(parentId, { + workspaceId: params.workspaceId, + resourceType: 'file', + }) + if (parentError) return { success: false, ...parentError } + + try { + const created = await createWorkspaceFileFolder({ + id: folderId, + workspaceId: params.workspaceId, + userId: params.userId, + name: params.name, + parentId, + sortOrder: params.sortOrder, + }) + + recordAudit({ + workspaceId: params.workspaceId, + actorId: params.userId, + action: AuditAction.FOLDER_CREATED, + resourceType: AuditResourceType.FOLDER, + resourceId: created.id, + resourceName: created.name, + description: `Created file folder "${created.name}"`, + }) + + return { success: true, folder: toFileFolder(created) } + } catch (error) { + // Propagate uncaught so the route's ResourceLockedError -> 423 handling applies. + if (error instanceof ResourceLockedError) { + throw error + } + if ( + error instanceof WorkspaceFileFolderConflictError || + getPostgresErrorCode(error) === '23505' + ) { + return { success: false, error: toError(error).message, errorCode: 'conflict' } + } + if (toError(error).message === 'Target folder not found') { + return { success: false, error: 'Target folder not found', errorCode: 'validation' } + } + logger.error('Failed to create file folder', { error }) + return { success: false, error: 'Internal server error', errorCode: 'internal' } + } +} + +async function performUpdateFileFolder( + params: PerformUpdateFolderParams +): Promise { + if (params.parentId && params.parentId === params.folderId) { + return { success: false, error: 'Folder cannot be its own parent', errorCode: 'validation' } + } + if (params.parentId) { + const parentError = await assertFolderParentValid(params.parentId, { + workspaceId: params.workspaceId, + resourceType: 'file', + }) + if (parentError) return { success: false, ...parentError } + } + + try { + const updated = await updateWorkspaceFileFolder({ + workspaceId: params.workspaceId, + folderId: params.folderId, + name: params.name, + parentId: params.parentId, + sortOrder: params.sortOrder, + locked: params.locked, + }) + + recordAudit({ + workspaceId: params.workspaceId, + actorId: params.userId, + action: AuditAction.FOLDER_UPDATED, + resourceType: AuditResourceType.FOLDER, + resourceId: params.folderId, + resourceName: updated.name, + description: `Updated file folder "${updated.name}"`, + }) + + return { success: true, folder: toFileFolder(updated) } + } catch (error) { + // Propagate uncaught so the route's ResourceLockedError -> 423 handling applies. + if (error instanceof ResourceLockedError) { + throw error + } + if ( + error instanceof WorkspaceFileFolderConflictError || + getPostgresErrorCode(error) === '23505' + ) { + return { success: false, error: toError(error).message, errorCode: 'conflict' } + } + const message = toError(error).message + if (message === 'Folder not found') { + return { success: false, error: message, errorCode: 'not_found' } + } + if ( + message === 'Folder cannot be its own parent' || + message === 'Target folder not found' || + message === 'Cannot move a folder into one of its descendants' + ) { + return { success: false, error: message, errorCode: 'validation' } + } + logger.error('Failed to update file folder', { error }) + return { success: false, error: 'Internal server error', errorCode: 'internal' } + } +} + +async function performDeleteFileFolder( + params: PerformDeleteFolderParams +): Promise { + const { folderId, workspaceId, userId, folderName } = params + + try { + const deletedItems = await archiveWorkspaceFileFolderRecursive(workspaceId, folderId) + + logger.info('Deleted file folder and contents', { folderId, ...deletedItems }) + + recordAudit({ + workspaceId, + actorId: userId, + action: AuditAction.FOLDER_DELETED, + resourceType: AuditResourceType.FOLDER, + resourceId: folderId, + resourceName: folderName, + description: `Deleted file folder "${folderName || folderId}"`, + metadata: { + affected: { files: deletedItems.files, subfolders: deletedItems.folders - 1 }, + }, + }) + + return { success: true, deletedItems } + } catch (error) { + // Propagate uncaught so the route's ResourceLockedError -> 423 handling applies. + if (error instanceof ResourceLockedError) { + throw error + } + if (toError(error).message === 'Folder not found') { + return { success: false, error: 'Folder not found', errorCode: 'not_found' } + } + logger.error('Failed to delete file folder', { error }) + return { success: false, error: 'Internal server error', errorCode: 'internal' } + } +} + +async function performRestoreFileFolder( + params: PerformRestoreFolderParams +): Promise { + const { folderId, workspaceId, userId, folderName } = params + + try { + const { folder, restoredItems } = await restoreWorkspaceFileFolder(workspaceId, folderId) + + logger.info('Restored file folder and contents', { folderId, restoredItems }) + + recordAudit({ + workspaceId, + actorId: userId, + action: AuditAction.FOLDER_RESTORED, + resourceType: AuditResourceType.FOLDER, + resourceId: folderId, + resourceName: folderName ?? folder.name, + description: `Restored file folder "${folderName ?? folder.name}"`, + metadata: { + affected: { + files: restoredItems.files, + subfolders: Math.max(0, restoredItems.folders - 1), + }, + }, + }) + + return { success: true, restoredItems } + } catch (error) { + // Propagate uncaught -- matches performRestoreWorkflowFolder/performRestoreResourceFolder, + // which never catch it, so the route's ResourceLockedError -> 423 handling applies uniformly. + if (error instanceof ResourceLockedError) { + throw error + } + if ( + error instanceof WorkspaceFileFolderConflictError || + getPostgresErrorCode(error) === '23505' + ) { + return { + success: false, + error: 'A folder with this name already exists in this location', + errorCode: 'conflict', + } + } + const message = toError(error).message + if (message === 'Folder not found') { + return { success: false, error: message, errorCode: 'not_found' } + } + if (message === 'Folder is not archived') { + return { success: false, error: message, errorCode: 'validation' } + } + if (message === 'Cannot restore folder into an archived workspace') { + return { success: false, error: message, errorCode: 'validation' } + } + logger.error('Failed to restore file folder', { error }) + return { success: false, error: 'Internal server error', errorCode: 'internal' } + } +} + +/** + * Walks `parentId` chains within a single resourceType to collect a folder's + * full subtree (itself + all descendants), scoped to currently-active + * (non-deleted) folders. Delegates the pure walk to the shared + * `collectDescendantFolderIds` helper also used by the file-folder cascade in + * `uploads/contexts/workspace/workspace-file-folder-manager.ts`. + */ +async function collectFolderSubtreeIds( + workspaceId: string, + resourceType: FolderResourceType, + folderId: string, + dbClient: DbOrTx = db +): Promise { + const activeFolders = await dbClient + .select({ id: folderTable.id, parentId: folderTable.parentId }) + .from(folderTable) + .where( + and( + eq(folderTable.workspaceId, workspaceId), + eq(folderTable.resourceType, resourceType), + isNull(folderTable.deletedAt) + ) + ) + + return [folderId, ...collectDescendantFolderIds(activeFolders, folderId)] +} + +/** + * `knowledge_base` and `table` folder cascades are identical except for the + * target table and its soft-delete column (`knowledgeBase.deletedAt` vs + * `userTableDefinitions.archivedAt`). This config captures that one delta so + * {@link performDeleteResourceFolder}/{@link performRestoreResourceFolder} + * implement the cascade logic exactly once. + */ +interface FolderCascadeConfig { + resourceType: 'knowledge_base' | 'table' + countKey: TCountKey + /** Soft-deletes every contained resource row whose folderId is in `folderIds`. */ + archiveChildren: ( + tx: DbOrTx, + folderIds: string[], + workspaceId: string, + now: Date + ) => Promise<{ id: string }[]> + /** Restores contained resource rows directly under `folderId` whose soft-delete timestamp matches. */ + restoreChildren: ( + tx: DbOrTx, + folderId: string, + workspaceId: string, + matchTimestamp: Date + ) => Promise<{ id: string }[]> +} + +const KNOWLEDGE_BASE_FOLDER_CASCADE: FolderCascadeConfig<'knowledgeBases'> = { + resourceType: 'knowledge_base', + countKey: 'knowledgeBases', + archiveChildren: (tx, folderIds, workspaceId, now) => + tx + .update(knowledgeBase) + .set({ deletedAt: now, updatedAt: now }) + .where( + and( + inArray(knowledgeBase.folderId, folderIds), + eq(knowledgeBase.workspaceId, workspaceId), + isNull(knowledgeBase.deletedAt) + ) + ) + .returning({ id: knowledgeBase.id }), + restoreChildren: (tx, folderId, workspaceId, matchTimestamp) => + tx + .update(knowledgeBase) + .set({ deletedAt: null, updatedAt: new Date() }) + .where( + and( + eq(knowledgeBase.folderId, folderId), + eq(knowledgeBase.workspaceId, workspaceId), + eq(knowledgeBase.deletedAt, matchTimestamp) + ) + ) + .returning({ id: knowledgeBase.id }), +} + +const TABLE_FOLDER_CASCADE: FolderCascadeConfig<'tables'> = { + resourceType: 'table', + countKey: 'tables', + archiveChildren: (tx, folderIds, workspaceId, now) => + tx + .update(userTableDefinitions) + .set({ archivedAt: now, updatedAt: now }) + .where( + and( + inArray(userTableDefinitions.folderId, folderIds), + eq(userTableDefinitions.workspaceId, workspaceId), + isNull(userTableDefinitions.archivedAt) + ) + ) + .returning({ id: userTableDefinitions.id }), + restoreChildren: (tx, folderId, workspaceId, matchTimestamp) => + tx + .update(userTableDefinitions) + .set({ archivedAt: null, updatedAt: new Date() }) + .where( + and( + eq(userTableDefinitions.folderId, folderId), + eq(userTableDefinitions.workspaceId, workspaceId), + eq(userTableDefinitions.archivedAt, matchTimestamp) + ) + ) + .returning({ id: userTableDefinitions.id }), +} + +function cascadeResourceLabel(resourceType: 'knowledge_base' | 'table'): string { + return resourceType === 'table' ? 'table' : 'knowledge base' +} + +/** + * Deletes a `knowledge_base`/`table` folder and cascades to its subtree and + * contained resources, all inside a single transaction so a crash mid-cascade + * can't leave folders archived without their contents (or vice versa). + */ +async function performDeleteResourceFolder( + params: PerformDeleteFolderParams, + cascade: FolderCascadeConfig +): Promise { + const { folderId, workspaceId, userId, folderName } = params + const now = new Date() + + const result = await db.transaction(async (tx) => { + const [existing] = await tx + .select({ id: folderTable.id }) + .from(folderTable) + .where( + and( + eq(folderTable.id, folderId), + eq(folderTable.workspaceId, workspaceId), + eq(folderTable.resourceType, cascade.resourceType), + isNull(folderTable.deletedAt) + ) + ) + .limit(1) + if (!existing) return null + + // The route checks lock state before calling this function, but that's a + // separate round-trip -- an admin could lock this folder in the window + // between that check and this transaction. Re-check inside the transaction + // (joining `tx` so the read is part of the same atomic unit as the writes + // below) before applying anything. + await assertFolderMutable(folderId, cascade.resourceType, tx) + + const folderIds = await collectFolderSubtreeIds(workspaceId, cascade.resourceType, folderId, tx) + + // `assertFolderMutable` above only checks `folderId` and its ancestors -- it never + // inspects descendants. `collectFolderSubtreeIds`'s read is also unlocked. Without + // this, a subfolder directly locked (not merely inheriting a lock from `folderId`) + // would be silently swept into this delete, since the UPDATE below has no lock + // filter. `FOR UPDATE` here row-locks every subtree member for the rest of this + // transaction and matches the workflow cascade, which checks each folder's own + // lock as it recurses into every descendant. + const subtreeRows = await tx + .select({ id: folderTable.id, locked: folderTable.locked }) + .from(folderTable) + .where( + and(inArray(folderTable.id, folderIds), eq(folderTable.resourceType, cascade.resourceType)) + ) + .for('update') + if (subtreeRows.some((row) => row.locked)) { + throw new ResourceLockedError( + cascade.resourceType, + false, + 'A folder in this location is locked' + ) + } + + const archivedFolders = await tx + .update(folderTable) + .set({ deletedAt: now, updatedAt: now }) + .where( + and( + inArray(folderTable.id, folderIds), + eq(folderTable.workspaceId, workspaceId), + eq(folderTable.resourceType, cascade.resourceType), + isNull(folderTable.deletedAt) + ) + ) + .returning({ id: folderTable.id }) + + const archivedChildren = await cascade.archiveChildren(tx, folderIds, workspaceId, now) + + return { folders: archivedFolders.length, children: archivedChildren.length } + }) + + if (!result) return { success: false, error: 'Folder not found', errorCode: 'not_found' } + + logger.info(`Deleted ${cascade.resourceType} folder and contents`, { + folderId, + folders: result.folders, + [cascade.countKey]: result.children, + }) + + recordAudit({ + workspaceId, + actorId: userId, + action: AuditAction.FOLDER_DELETED, + resourceType: AuditResourceType.FOLDER, + resourceId: folderId, + resourceName: folderName, + description: `Deleted ${cascadeResourceLabel(cascade.resourceType)} folder "${folderName || folderId}"`, + metadata: { + affected: { [cascade.countKey]: result.children, subfolders: result.folders - 1 }, + }, + }) + + const deletedItems: NonNullable = + cascade.countKey === 'knowledgeBases' + ? { folders: result.folders, knowledgeBases: result.children } + : { folders: result.folders, tables: result.children } + + return { success: true, deletedItems } +} + +/** + * Restores a soft-deleted `knowledge_base`/`table` folder and its subtree, + * only resurrecting folders/resources archived at the same instant (matched + * by timestamp) as the folder itself. All writes run in a single transaction. + */ +async function performRestoreResourceFolder( + params: PerformRestoreFolderParams, + cascade: FolderCascadeConfig +): Promise { + const { folderId, workspaceId, userId, folderName } = params + + const outcome = await db.transaction(async (tx) => { + // `FOR UPDATE` row-locks this folder for the rest of the transaction -- a plain + // SELECT inside a transaction does not block a concurrent UPDATE, so without this + // a lock toggled on this row after this read but before the restore write below + // could still be silently bypassed. + const [raw] = await tx + .select() + .from(folderTable) + .where( + and( + eq(folderTable.id, folderId), + eq(folderTable.workspaceId, workspaceId), + eq(folderTable.resourceType, cascade.resourceType) + ) + ) + .for('update') + .limit(1) + if (!raw) return { kind: 'not_found' as const } + if (!raw.deletedAt) return { kind: 'not_archived' as const } + + // The folder row is soft-deleted, so the generic lock engine (which only reads + // active rows) can't see it -- check its own `locked` flag directly. + if (raw.locked) { + throw new ResourceLockedError(cascade.resourceType, false, 'Folder is locked') + } + + const folderDeletedAt = raw.deletedAt + + // Only restore the same subtree that was archived together (matched by + // deletedAt timestamp) — sibling folders/resources soft-deleted + // independently before or after this folder's delete must not resurrect. + const stats = { folders: 0, children: 0 } + const seen = new Set() + const restoreSubtree = async (currentFolderId: string): Promise => { + if (seen.has(currentFolderId)) return + seen.add(currentFolderId) + + const restoredChildren = await cascade.restoreChildren( + tx, + currentFolderId, + workspaceId, + folderDeletedAt + ) + stats.children += restoredChildren.length + + const archivedChildFolders = await tx + .select({ id: folderTable.id }) + .from(folderTable) + .where( + and( + eq(folderTable.parentId, currentFolderId), + eq(folderTable.workspaceId, workspaceId), + eq(folderTable.resourceType, cascade.resourceType), + eq(folderTable.deletedAt, folderDeletedAt) + ) + ) + for (const child of archivedChildFolders) { + const [restoredChild] = await tx + .update(folderTable) + .set({ deletedAt: null, updatedAt: new Date() }) + .where(and(eq(folderTable.id, child.id), eq(folderTable.deletedAt, folderDeletedAt))) + .returning({ id: folderTable.id }) + if (!restoredChild) continue + stats.folders += 1 + await restoreSubtree(child.id) + } + } + + // If the parent folder is still archived, restore to root rather than + // leaving the folder orphaned under an archived parent. + let resolvedParentId = raw.parentId + if (resolvedParentId) { + // FOR UPDATE closes the window where a concurrent transaction soft-deletes + // this parent between this read and the write below -- without it, this + // plain read isn't locked, so the parent could be deleted out from under us + // after we've already decided it's active, restoring this folder under a + // parent that no longer exists. + const [parent] = await tx + .select({ deletedAt: folderTable.deletedAt }) + .from(folderTable) + .where(eq(folderTable.id, resolvedParentId)) + .for('update') + .limit(1) + if (!parent || parent.deletedAt) resolvedParentId = null + } + + // resolvedParentId is either null (root, always safe) or a confirmed-active + // folder -- without this, the folder could resurface under a folder that was + // locked after this one was deleted. Passing `tx` (not the default db client) + // keeps this read inside the same transaction as the write below, closing the + // TOCTOU window where a concurrent request locks resolvedParentId in between. + await assertFolderMutable(resolvedParentId, cascade.resourceType, tx) + + await tx + .update(folderTable) + .set({ deletedAt: null, parentId: resolvedParentId, updatedAt: new Date() }) + .where(eq(folderTable.id, folderId)) + stats.folders += 1 + await restoreSubtree(folderId) + + return { kind: 'ok' as const, stats, name: raw.name } + }) + + if (outcome.kind === 'not_found') { + return { success: false, error: 'Folder not found', errorCode: 'not_found' } + } + if (outcome.kind === 'not_archived') { + return { success: false, error: 'Folder is not archived', errorCode: 'validation' } + } + + const { stats, name } = outcome + + logger.info(`Restored ${cascade.resourceType} folder and contents`, { folderId, ...stats }) + + recordAudit({ + workspaceId, + actorId: userId, + action: AuditAction.FOLDER_RESTORED, + resourceType: AuditResourceType.FOLDER, + resourceId: folderId, + resourceName: folderName ?? name, + description: `Restored ${cascadeResourceLabel(cascade.resourceType)} folder "${folderName ?? name}"`, + metadata: { + affected: { + [cascade.countKey]: stats.children, + subfolders: Math.max(0, stats.folders - 1), + }, + }, + }) + + const restoredItems: NonNullable = + cascade.countKey === 'knowledgeBases' + ? { folders: stats.folders, knowledgeBases: stats.children } + : { folders: stats.folders, tables: stats.children } + + return { success: true, restoredItems } +} + +/** + * `knowledge_base`/`table` resource rows carry no `sortOrder` column of their own + * (unlike `workflow`, which blends workflow and folder sort order) -- so a new + * kb/table folder only needs to sort above its sibling *folders*. Mirrors + * `nextFolderSortOrder`/`nextWorkflowSortOrder` in + * `workflows/orchestration/{folder,workflow}-lifecycle.ts` for the one input + * these two resourceTypes actually have. + */ +async function nextResourceFolderSortOrder( + workspaceId: string, + resourceType: FolderResourceType, + parentId: string | null +): Promise { + const [result] = await db + .select({ minSortOrder: min(folderTable.sortOrder) }) + .from(folderTable) + .where( + and( + eq(folderTable.workspaceId, workspaceId), + eq(folderTable.resourceType, resourceType), + parentId ? eq(folderTable.parentId, parentId) : isNull(folderTable.parentId), + isNull(folderTable.deletedAt) + ) + ) + return result?.minSortOrder != null ? result.minSortOrder - 1 : 0 +} + +/** + * Re-validates that `parentId` is still an active (non-deleted) folder of + * `resourceType`, `FOR UPDATE`-locking the row for the rest of the caller's + * transaction. Shared by {@link performCreateFolder} and + * {@link performUpdateFolder}, which each need this exact recheck (their own + * `assertFolderMutable` call happens beforehand, at the call site, since the + * two callers precede it with different comments/context). + */ +async function assertTargetParentActive( + tx: DbOrTx, + parentId: string, + resourceType: FolderResourceType +): Promise { + const [targetParent] = await tx + .select({ id: folderTable.id }) + .from(folderTable) + .where( + and( + eq(folderTable.id, parentId), + eq(folderTable.resourceType, resourceType), + isNull(folderTable.deletedAt) + ) + ) + .for('update') + .limit(1) + if (!targetParent) { + throw new FolderParentNotFoundError('Parent folder not found') + } +} + +export async function performCreateFolder( + params: PerformCreateFolderParams +): Promise { + if (params.resourceType === 'workflow') { + return performCreateWorkflowFolder(params) + } + if (params.resourceType === 'file') { + return performCreateFileFolder(params) + } + // knowledge_base / table: plain create, no cascade concerns yet. + const folderId = params.id || generateId() + const parentId = params.parentId || null + if (parentId === folderId) { + return { success: false, error: 'Folder cannot be its own parent', errorCode: 'validation' } + } + const parentError = await assertFolderParentValid(parentId, { + workspaceId: params.workspaceId, + resourceType: params.resourceType, + }) + if (parentError) return { success: false, ...parentError } + + const sortOrder = + params.sortOrder !== undefined + ? params.sortOrder + : await nextResourceFolderSortOrder(params.workspaceId, params.resourceType, parentId) + + try { + const created = await db.transaction(async (tx) => { + if (parentId) { + // assertFolderParentValid above only reads at validation time, using the + // default (non-tx) db client -- a parent locked or soft-deleted after that + // read but before this transaction must still be caught. Re-check inside + // the transaction (joining `tx`) before inserting. + await assertFolderMutable(parentId, params.resourceType, tx) + + // assertFolderMutable's lock walker treats a deleted parent as unlocked + // (it stops the ancestor walk when the row is missing rather than + // erroring), so it alone doesn't catch a parent deleted in that same + // window. FOR UPDATE here makes the recheck race-free. + await assertTargetParentActive(tx, parentId, params.resourceType) + } + + const [row] = await tx + .insert(folderTable) + .values({ + id: folderId, + resourceType: params.resourceType, + name: params.name.trim(), + userId: params.userId, + workspaceId: params.workspaceId, + parentId, + sortOrder, + }) + .returning() + return row + }) + + recordAudit({ + workspaceId: params.workspaceId, + actorId: params.userId, + action: AuditAction.FOLDER_CREATED, + resourceType: AuditResourceType.FOLDER, + resourceId: created.id, + resourceName: created.name, + description: `Created ${cascadeResourceLabel(params.resourceType)} folder "${created.name}"`, + }) + + return { success: true, folder: created } + } catch (error) { + // Propagate uncaught so the route's ResourceLockedError -> 423 handling applies. + if (error instanceof ResourceLockedError) { + throw error + } + if (error instanceof FolderParentNotFoundError) { + return { success: false, error: error.message, errorCode: 'validation' } + } + if (getPostgresErrorCode(error) === '23505') { + return { + success: false, + error: 'A folder with this name already exists in this location', + errorCode: 'conflict', + } + } + throw error + } +} + +export async function performUpdateFolder( + params: PerformUpdateFolderParams +): Promise { + if (params.resourceType === 'workflow') { + return performUpdateWorkflowFolder(params) + } + if (params.resourceType === 'file') { + return performUpdateFileFolder(params) + } + + if (params.parentId && params.parentId === params.folderId) { + return { success: false, error: 'Folder cannot be its own parent', errorCode: 'validation' } + } + if (params.parentId) { + const parentError = await assertFolderParentValid(params.parentId, { + workspaceId: params.workspaceId, + resourceType: params.resourceType, + }) + if (parentError) return { success: false, ...parentError } + + const wouldCreateCycle = await checkFolderCircularReference(params.folderId, params.parentId) + if (wouldCreateCycle) { + return { + success: false, + error: 'Cannot create circular folder reference', + errorCode: 'validation', + } + } + } + + const updates: Record = { updatedAt: new Date() } + if (params.name !== undefined) updates.name = params.name.trim() + if (params.parentId !== undefined) updates.parentId = params.parentId || null + if (params.sortOrder !== undefined) updates.sortOrder = params.sortOrder + if (params.locked !== undefined) updates.locked = params.locked + + const isLockOnlyUpdate = + params.name === undefined && params.parentId === undefined && params.sortOrder === undefined + + try { + const updated = await db.transaction(async (tx) => { + // The route checks lock state before calling this function, but that's a + // separate round-trip -- an admin could lock this folder (or its target + // parent) in the window between that check and this transaction. Re-check + // inside the transaction (joining `tx` so the read is part of the same + // atomic unit as the write below) before applying anything. + // + // `assertFolderMutableUnlessUnlocking` only treats this folder's own + // (about-to-be-cleared) lock as satisfied when this same request unlocks it -- + // a lock inherited from an ancestor still blocks. Skipped entirely for a + // lock-only update, matching the file/kb/table lock-toggle precedent. + if (!isLockOnlyUpdate) { + await assertFolderMutableUnlessUnlocking( + params.folderId, + params.resourceType, + params.locked === false, + tx + ) + } + if (params.parentId) { + // A folder in an unlocked location could otherwise be moved into a locked one. + await assertFolderMutable(params.parentId, params.resourceType, tx) + + // assertFolderMutable's lock walker treats a deleted parent as unlocked (it + // stops the ancestor walk when the row is missing, rather than erroring), so + // it alone doesn't catch a parent deleted between the earlier + // assertFolderParentValid precheck and this transaction. FOR UPDATE here + // makes the recheck race-free: once held, the parent's deletedAt can't + // change until this transaction resolves. + await assertTargetParentActive(tx, params.parentId, params.resourceType) + + // The cycle check above (`checkFolderCircularReference`) ran against an + // unlocked pre-transaction snapshot -- a concurrent reparent could form a + // cycle in the window between that read and this transaction (the batch + // reorder path was hardened against exactly this race). Re-run it here + // against `tx` so it observes the same FOR-UPDATE-locked parent chain this + // transaction is about to write. + const wouldCreateCycleInTx = await checkFolderCircularReference( + params.folderId, + params.parentId, + tx + ) + if (wouldCreateCycleInTx) { + throw new FolderCycleError('Cannot create circular folder reference') + } + } + + const [row] = await tx + .update(folderTable) + .set(updates) + .where( + and( + eq(folderTable.id, params.folderId), + eq(folderTable.workspaceId, params.workspaceId), + eq(folderTable.resourceType, params.resourceType), + isNull(folderTable.deletedAt) + ) + ) + .returning() + return row + }) + + if (!updated) return { success: false, error: 'Folder not found', errorCode: 'not_found' } + + recordAudit({ + workspaceId: params.workspaceId, + actorId: params.userId, + action: AuditAction.FOLDER_UPDATED, + resourceType: AuditResourceType.FOLDER, + resourceId: updated.id, + resourceName: updated.name, + description: `Updated ${cascadeResourceLabel(params.resourceType)} folder "${updated.name}"`, + }) + + return { success: true, folder: updated } + } catch (error) { + // Propagate uncaught so the route's ResourceLockedError -> 423 handling applies. + if (error instanceof ResourceLockedError) { + throw error + } + if (error instanceof FolderParentNotFoundError) { + return { success: false, error: error.message, errorCode: 'validation' } + } + if (error instanceof FolderCycleError) { + return { success: false, error: error.message, errorCode: 'validation' } + } + if (getPostgresErrorCode(error) === '23505') { + return { + success: false, + error: 'A folder with this name already exists in this location', + errorCode: 'conflict', + } + } + throw error + } +} + +export async function performDeleteFolder( + params: PerformDeleteFolderParams +): Promise { + if (params.resourceType === 'workflow') { + const result = await performDeleteWorkflowFolder(params) + return { + ...result, + deletedItems: result.deletedItems + ? { folders: result.deletedItems.folders, workflows: result.deletedItems.workflows } + : undefined, + } + } + if (params.resourceType === 'file') { + return performDeleteFileFolder(params) + } + if (params.resourceType === 'knowledge_base') { + return performDeleteResourceFolder(params, KNOWLEDGE_BASE_FOLDER_CASCADE) + } + return performDeleteResourceFolder(params, TABLE_FOLDER_CASCADE) +} + +export async function performRestoreFolder( + params: PerformRestoreFolderParams +): Promise { + if (params.resourceType === 'workflow') { + return performRestoreWorkflowFolder(params) + } + if (params.resourceType === 'file') { + return performRestoreFileFolder(params) + } + if (params.resourceType === 'knowledge_base') { + return performRestoreResourceFolder(params, KNOWLEDGE_BASE_FOLDER_CASCADE) + } + return performRestoreResourceFolder(params, TABLE_FOLDER_CASCADE) +} + +/** Marks a concurrent-deletion race on a target parent, caught inside a folder-update transaction, as a 400 (validation), not a 500. */ +class FolderParentNotFoundError extends Error {} + +/** Marks a concurrent-deletion race caught inside the reorder transaction as a 404, not a 500. */ +class FolderReorderNotFoundError extends Error {} +/** Marks a cycle caught inside the reorder transaction as a 400, not a 500. */ +class FolderCycleError extends Error {} +/** Marks a closure that went stale between discovery and locking, caught inside the reorder transaction, as a 409, not a 500. */ +class FolderReorderStaleClosureError extends Error {} + +export async function performReorderFolders(params: PerformReorderFoldersParams): Promise<{ + success: boolean + updated: number + error?: string + errorCode?: OrchestrationErrorCode +}> { + const { resourceType, workspaceId, updates } = params + + const folderIds = updates.map((u) => u.id) + const existingFolders = await db + .select({ id: folderTable.id, workspaceId: folderTable.workspaceId }) + .from(folderTable) + .where( + and( + inArray(folderTable.id, folderIds), + eq(folderTable.resourceType, resourceType), + isNull(folderTable.deletedAt) + ) + ) + + const validIds = new Set( + existingFolders.filter((f) => f.workspaceId === workspaceId).map((f) => f.id) + ) + // Any id that doesn't resolve to an existing, active, same-workspace folder (wrong + // workspace, wrong resourceType, or soft-deleted) fails the whole batch up front -- + // matching the parentId check below -- rather than silently reordering a subset and + // reporting success with a smaller `updated` count. + const invalidId = updates.find((u) => !validIds.has(u.id)) + if (invalidId) { + return { + success: false, + updated: 0, + error: 'One or more folders were not found', + errorCode: 'not_found', + } + } + + // Reparents also need `assertFolderParentValid` on the new parent (the + // `validIds` check above only validates `id`). Any invalid parentId fails + // the whole batch up front rather than silently skipping that entry. + // Distinct target parentIds are validated concurrently since a subtree + // drag-drop can reparent many folders in one call. + const targetParentIds = Array.from( + new Set(updates.map((u) => u.parentId).filter((id): id is string => Boolean(id))) + ) + const parentErrors = await Promise.all( + targetParentIds.map((parentId) => + assertFolderParentValid(parentId, { workspaceId, resourceType }) + ) + ) + const firstParentError = parentErrors.find((error) => error !== null) + if (firstParentError) { + return { + success: false, + updated: 0, + error: firstParentError.error, + errorCode: firstParentError.errorCode, + } + } + + try { + await db.transaction(async (tx) => { + // The route checks lock state before calling this function, but that's a + // separate round-trip -- an admin could lock a source folder or a target + // parent in the window between that check and this transaction. Re-check + // inside the transaction (joining `tx` so the read is part of the same + // atomic unit as the writes below) before applying anything. + // + // A batch can touch several independent folders (not one linear ancestor + // chain), so locking each one's ancestor chain separately -- even in a + // consistently sorted order of the *starting* ids -- can still deadlock + // against a concurrent batch: each chain walk acquires its own rows one at a + // time, and two batches whose chains interleave differently can each end up + // holding a row the other needs. The only way to make batch-vs-batch lock + // acquisition safe is to compute the full set of rows that need locking + // up front and lock all of them in a single statement, ordered by id -- + // concurrent transactions that do the same always converge on the same + // acquisition order and cannot form a cycle. + const startIds = Array.from(new Set([...updates.map((u) => u.id), ...targetParentIds])) + const closure = new Set(startIds) + let frontier = new Set(startIds) + while (frontier.size > 0) { + const rows = await tx + .select({ id: folderTable.id, parentId: folderTable.parentId }) + .from(folderTable) + .where( + and( + inArray(folderTable.id, Array.from(frontier)), + eq(folderTable.resourceType, resourceType), + isNull(folderTable.deletedAt) + ) + ) + const nextFrontier = new Set() + for (const row of rows) { + if (row.parentId && !closure.has(row.parentId)) { + closure.add(row.parentId) + nextFrontier.add(row.parentId) + } + } + frontier = nextFrontier + } + + // Selecting without an isNull(deletedAt) filter and re-checking it here (rather + // than trusting the closure walk above, which reads before this lock is held) + // is what makes this race-free: a row can still be soft-deleted by another + // transaction right up until this statement acquires FOR UPDATE, but not after + // -- so `deletedAt` on a just-locked row is guaranteed accurate for the rest of + // this transaction. + const lockedRows = await tx + .select({ + id: folderTable.id, + parentId: folderTable.parentId, + locked: folderTable.locked, + deletedAt: folderTable.deletedAt, + }) + .from(folderTable) + .where( + and( + inArray(folderTable.id, Array.from(closure).sort()), + eq(folderTable.resourceType, resourceType) + ) + ) + .orderBy(folderTable.id) + .for('update') + + const activeLockedIds = new Set( + lockedRows.filter((row) => !row.deletedAt).map((row) => row.id) + ) + // Re-check every target parent resolved to an active row under this lock -- + // assertFolderParentValid only reads at validation time, so a parent + // concurrently soft-deleted before (or racing) this transaction could + // otherwise leave an active folder pointing at a deleted one. + if (targetParentIds.some((id) => !activeLockedIds.has(id))) { + throw new FolderReorderNotFoundError('Parent folder not found') + } + + if (lockedRows.some((row) => !row.deletedAt && row.locked)) { + throw new ResourceLockedError(resourceType, false, 'Folder is locked') + } + + // The ancestor-closure walk above discovers members with PLAIN reads before + // this FOR UPDATE lock is acquired -- a concurrent transaction could reparent + // one of those ancestors to point outside the discovered closure in that gap. + // If so, the locked read below reveals a parentId the cycle walk has no data + // for, and treating "not in closure" as "reached root" (as `?? null` would) + // could silently let a real cycle through undetected. Fail the whole batch + // safely -- the client can retry -- rather than risk that. + const closureIds = new Set(lockedRows.map((row) => row.id)) + if (lockedRows.some((row) => row.parentId && !closureIds.has(row.parentId))) { + throw new FolderReorderStaleClosureError('Folder tree changed concurrently, please retry') + } + + // The route's own cycle check reads an unlocked snapshot of the folder tree + // before this transaction opens -- two concurrent reorder requests can each + // move one end of what becomes an A<->B cycle, both passing their own + // (mutually stale) check. A cycle can only newly appear through an edge this + // batch is writing, and every folder whose parentId this batch changes is a + // `startId`, so re-walking from every `startId` using this transaction's own + // locked, tx-consistent `parentId` values (closure already contains every + // node such a walk could reach) reliably catches it before the write below. + const lockedParentById = new Map(lockedRows.map((row) => [row.id, row.parentId])) + for (const update of updates) { + if (update.parentId !== undefined) { + lockedParentById.set(update.id, update.parentId || null) + } + } + for (const update of updates) { + const visited = new Set() + let cursor: string | null = update.id + while (cursor) { + if (visited.has(cursor)) { + throw new FolderCycleError('Cannot create circular folder reference') + } + visited.add(cursor) + cursor = lockedParentById.get(cursor) ?? null + } + } + + for (const update of updates) { + const updateData: Record = { + sortOrder: update.sortOrder, + updatedAt: new Date(), + } + if (update.parentId !== undefined) updateData.parentId = update.parentId || null + + // Re-check deletedAt at write time (not just the validation read above) -- + // without this, a folder concurrently soft-deleted between validation and + // this transaction could still have its sortOrder/parentId mutated. Throwing + // rolls back the whole transaction rather than silently applying a partial + // batch, matching this function's fail-whole-batch behavior on other errors. + const [updated] = await tx + .update(folderTable) + .set(updateData) + .where(and(eq(folderTable.id, update.id), isNull(folderTable.deletedAt))) + .returning({ id: folderTable.id }) + if (!updated) { + throw new FolderReorderNotFoundError('One or more folders were not found') + } + } + }) + } catch (error) { + // Propagate uncaught so the route's ResourceLockedError -> 423 handling applies. + if (error instanceof ResourceLockedError) { + throw error + } + if (error instanceof FolderReorderNotFoundError) { + return { success: false, updated: 0, error: error.message, errorCode: 'not_found' } + } + if (error instanceof FolderCycleError) { + return { success: false, updated: 0, error: error.message, errorCode: 'validation' } + } + if (error instanceof FolderReorderStaleClosureError) { + return { success: false, updated: 0, error: error.message, errorCode: 'conflict' } + } + // An unexpected DB/transaction failure, not a client-caused validation error -- + // log the real cause but don't leak internal error details to the response. + logger.error('Unexpected error reordering folders', { error }) + return { + success: false, + updated: 0, + error: 'Failed to reorder folders', + errorCode: 'internal', + } + } + + return { success: true, updated: updates.length } +} diff --git a/apps/sim/lib/folders/parent-validation.test.ts b/apps/sim/lib/folders/parent-validation.test.ts new file mode 100644 index 00000000000..b24e6b81ddc --- /dev/null +++ b/apps/sim/lib/folders/parent-validation.test.ts @@ -0,0 +1,120 @@ +/** + * @vitest-environment node + * + * `checkFolderCircularReference` is the generic (resourceType-agnostic) cycle + * guard shared by all four folder update paths -- regression coverage for the + * bug where the knowledge_base/table branch of performUpdateFolder had no + * cycle check at all (only workflow/file did), letting a folder be reparented + * into its own descendant and silently detach the subtree from the root. + */ +import { dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@sim/db', () => dbChainMock) + +import { + assertFolderParentValid, + checkFolderCircularReference, +} from '@/lib/folders/parent-validation' + +describe('checkFolderCircularReference', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + + it('detects a direct cycle (moving a folder under its immediate child)', async () => { + const result = await checkFolderCircularReference('folder-a', 'folder-a') + expect(result).toBe(true) + expect(dbChainMockFns.select).not.toHaveBeenCalled() + }) + + it('detects an indirect cycle (moving a folder under a deeper descendant)', async () => { + // folder-a is being moved under folder-c, whose chain is c -> b -> a. + dbChainMockFns.limit + .mockResolvedValueOnce([{ parentId: 'folder-b' }]) // folder-c's parent + .mockResolvedValueOnce([{ parentId: 'folder-a' }]) // folder-b's parent + + const result = await checkFolderCircularReference('folder-a', 'folder-c') + expect(result).toBe(true) + }) + + it('allows reparenting into an unrelated folder', async () => { + dbChainMockFns.limit + .mockResolvedValueOnce([{ parentId: 'folder-y' }]) + .mockResolvedValueOnce([{ parentId: null }]) + + const result = await checkFolderCircularReference('folder-a', 'folder-x') + expect(result).toBe(false) + }) + + it('allows reparenting to root (no ancestor chain to walk)', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([{ parentId: null }]) + + const result = await checkFolderCircularReference('folder-a', 'folder-x') + expect(result).toBe(false) + expect(dbChainMockFns.limit).toHaveBeenCalledTimes(1) + }) + + it('treats an already-corrupt chain (unrelated cycle) as a cycle rather than looping forever', async () => { + // folder-x -> folder-y -> folder-x (pre-existing corruption unrelated to folder-a). + dbChainMockFns.limit + .mockResolvedValueOnce([{ parentId: 'folder-y' }]) + .mockResolvedValueOnce([{ parentId: 'folder-x' }]) + + const result = await checkFolderCircularReference('folder-a', 'folder-x') + expect(result).toBe(true) + }) +}) + +describe('assertFolderParentValid', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + + it('returns null (valid) for a null parentId', async () => { + const result = await assertFolderParentValid(null, { + workspaceId: 'ws-1', + resourceType: 'knowledge_base', + }) + expect(result).toBeNull() + expect(dbChainMockFns.select).not.toHaveBeenCalled() + }) + + it('rejects a parent in a different workspace', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([ + { workspaceId: 'ws-other', resourceType: 'knowledge_base', deletedAt: null }, + ]) + + const result = await assertFolderParentValid('folder-1', { + workspaceId: 'ws-1', + resourceType: 'knowledge_base', + }) + expect(result).toMatchObject({ error: 'Parent folder not found', errorCode: 'validation' }) + }) + + it('rejects a soft-deleted parent', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([ + { workspaceId: 'ws-1', resourceType: 'knowledge_base', deletedAt: new Date() }, + ]) + + const result = await assertFolderParentValid('folder-1', { + workspaceId: 'ws-1', + resourceType: 'knowledge_base', + }) + expect(result).toMatchObject({ error: 'Parent folder not found', errorCode: 'validation' }) + }) + + it('accepts a valid, active, same-workspace, same-resourceType parent', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([ + { workspaceId: 'ws-1', resourceType: 'knowledge_base', deletedAt: null }, + ]) + + const result = await assertFolderParentValid('folder-1', { + workspaceId: 'ws-1', + resourceType: 'knowledge_base', + }) + expect(result).toBeNull() + }) +}) diff --git a/apps/sim/lib/folders/parent-validation.ts b/apps/sim/lib/folders/parent-validation.ts new file mode 100644 index 00000000000..1d56ffeaca2 --- /dev/null +++ b/apps/sim/lib/folders/parent-validation.ts @@ -0,0 +1,79 @@ +import { db } from '@sim/db' +import { folder } from '@sim/db/schema' +import { eq } from 'drizzle-orm' +import type { FolderResourceType } from '@/lib/api/contracts/folders' +import type { DbOrTx } from '@/lib/db/types' +import type { OrchestrationErrorCode } from '@/lib/workflows/orchestration/types' + +/** + * Validates a prospective parent folder for a create/update against the + * generic `folder` table: it must exist, be un-deleted, belong to the target + * workspace, and (defense-in-depth alongside the DB trigger) match resourceType. + * Mirrors the DB-level `folder_parent_resource_type_match` trigger. + * + * Kept in its own leaf module (no imports from `folders/orchestration.ts` or + * `workflows/orchestration/folder-lifecycle.ts`) so both of those modules — + * which import from each other — can share this single validation without a + * circular import. + */ +export async function assertFolderParentValid( + parentId: string | null | undefined, + ctx: { workspaceId: string; resourceType: FolderResourceType }, + dbClient: DbOrTx = db +): Promise<{ error: string; errorCode: OrchestrationErrorCode } | null> { + if (!parentId) return null + + const [parent] = await dbClient + .select({ + workspaceId: folder.workspaceId, + resourceType: folder.resourceType, + deletedAt: folder.deletedAt, + }) + .from(folder) + .where(eq(folder.id, parentId)) + .limit(1) + + if ( + !parent || + parent.workspaceId !== ctx.workspaceId || + parent.resourceType !== ctx.resourceType || + parent.deletedAt + ) { + return { error: 'Parent folder not found', errorCode: 'validation' } + } + + return null +} + +/** + * Walks `parentId` up from `newParentId` toward the root, returning `true` if + * it ever reaches `folderId` (or revisits a folder, guarding against an + * already-corrupt chain) -- i.e. whether reparenting `folderId` under + * `newParentId` would create a cycle. Folder ids are globally unique (not + * scoped per resourceType), so this needs no resourceType filter. + */ +export async function checkFolderCircularReference( + folderId: string, + newParentId: string, + dbClient: DbOrTx = db +): Promise { + let currentParentId: string | null = newParentId + const visited = new Set() + + while (currentParentId) { + if (visited.has(currentParentId) || currentParentId === folderId) { + return true + } + visited.add(currentParentId) + + const [parent] = await dbClient + .select({ parentId: folder.parentId }) + .from(folder) + .where(eq(folder.id, currentParentId)) + .limit(1) + + currentParentId = parent?.parentId ?? null + } + + return false +} diff --git a/apps/sim/lib/folders/policy.ts b/apps/sim/lib/folders/policy.ts new file mode 100644 index 00000000000..4adf103231a --- /dev/null +++ b/apps/sim/lib/folders/policy.ts @@ -0,0 +1,33 @@ +import { assertFolderMutable } from '@sim/platform-authz/resource-lock' +import type { FolderResourceType } from '@/lib/api/contracts/folders' + +/** + * Per-resourceType policy for folder mutations. All four resourceTypes now + * support the same lock-cascade enforcement via the generic locking engine + * in `@sim/platform-authz/resource-lock`. + */ +export interface FolderResourcePolicy { + /** Throws when `folderId` (or an ancestor) is locked. */ + assertMutable: (folderId: string | null) => Promise + /** Whether this resourceType's folders support the `locked` field at all. */ + supportsLocking: boolean +} + +export const FOLDER_RESOURCE_POLICIES: Record = { + workflow: { + assertMutable: (id) => assertFolderMutable(id, 'workflow'), + supportsLocking: true, + }, + file: { + assertMutable: (id) => assertFolderMutable(id, 'file'), + supportsLocking: true, + }, + knowledge_base: { + assertMutable: (id) => assertFolderMutable(id, 'knowledge_base'), + supportsLocking: true, + }, + table: { + assertMutable: (id) => assertFolderMutable(id, 'table'), + supportsLocking: true, + }, +} diff --git a/apps/sim/lib/folders/queries.ts b/apps/sim/lib/folders/queries.ts index 9518b1672d8..5b22b48c7b8 100644 --- a/apps/sim/lib/folders/queries.ts +++ b/apps/sim/lib/folders/queries.ts @@ -1,32 +1,35 @@ import { db } from '@sim/db' -import { workflowFolder } from '@sim/db/schema' +import { folder } from '@sim/db/schema' import { and, asc, eq, isNotNull, isNull } from 'drizzle-orm' -import type { FolderApi } from '@/lib/api/contracts/folders' +import type { FolderApi, FolderResourceType } from '@/lib/api/contracts/folders' import type { FolderQueryScope } from '@/hooks/queries/utils/folder-keys' /** Normalizes timestamp columns to ISO strings to honor the `FolderApi` wire contract. */ -function toFolderApi(row: typeof workflowFolder.$inferSelect): FolderApi { +function toFolderApi(row: typeof folder.$inferSelect): FolderApi { return { ...row, createdAt: row.createdAt.toISOString(), updatedAt: row.updatedAt.toISOString(), - archivedAt: row.archivedAt ? row.archivedAt.toISOString() : null, + deletedAt: row.deletedAt ? row.deletedAt.toISOString() : null, } } /** Shared by `GET /api/folders` and the sidebar prefetch so the query never drifts between them. */ export async function listFoldersForWorkspace( workspaceId: string, + resourceType: FolderResourceType, scope: FolderQueryScope ): Promise { - const archivedFilter = - scope === 'archived' ? isNotNull(workflowFolder.archivedAt) : isNull(workflowFolder.archivedAt) + const deletedFilter = + scope === 'archived' ? isNotNull(folder.deletedAt) : isNull(folder.deletedAt) const rows = await db .select() - .from(workflowFolder) - .where(and(eq(workflowFolder.workspaceId, workspaceId), archivedFilter)) - .orderBy(asc(workflowFolder.sortOrder), asc(workflowFolder.createdAt)) + .from(folder) + .where( + and(eq(folder.workspaceId, workspaceId), eq(folder.resourceType, resourceType), deletedFilter) + ) + .orderBy(asc(folder.sortOrder), asc(folder.createdAt)) return rows.map(toFolderApi) } diff --git a/apps/sim/lib/folders/resource-lock.test.ts b/apps/sim/lib/folders/resource-lock.test.ts new file mode 100644 index 00000000000..15b11b542f2 --- /dev/null +++ b/apps/sim/lib/folders/resource-lock.test.ts @@ -0,0 +1,237 @@ +/** + * @vitest-environment node + * + * Exercises the real `@sim/platform-authz/resource-lock` engine (not the + * globally-mocked version from vitest.setup.ts) against a scripted `@sim/db` + * chain mock, for all four `FolderResourceType`s. + */ +import { dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.unmock('@sim/platform-authz/resource-lock') +vi.unmock('@sim/platform-authz/workflow') + +vi.mock('@sim/db', () => ({ + ...dbChainMock, + folder: { id: 'folder.id', parentId: 'folder.parentId', locked: 'folder.locked' }, + folderResourceTypeEnum: { enumValues: ['workflow', 'file', 'knowledge_base', 'table'] }, + workflow: { id: 'workflow.id', locked: 'workflow.locked', folderId: 'workflow.folderId' }, + workspace: { id: 'workspace.id', organizationId: 'workspace.organizationId' }, + workspaceFiles: { + id: 'workspaceFiles.id', + locked: 'workspaceFiles.locked', + folderId: 'workspaceFiles.folderId', + }, + knowledgeBase: { + id: 'knowledgeBase.id', + locked: 'knowledgeBase.locked', + folderId: 'knowledgeBase.folderId', + }, + userTableDefinitions: { + id: 'userTableDefinitions.id', + locked: 'userTableDefinitions.locked', + folderId: 'userTableDefinitions.folderId', + }, +})) + +vi.mock('drizzle-orm', () => ({ + eq: vi.fn((a, b) => ({ type: 'eq', a, b })), + and: vi.fn((...conds) => ({ type: 'and', conds })), + isNull: vi.fn((a) => ({ type: 'isNull', a })), +})) + +import { + assertFolderMutable, + assertFolderMutableUnlessUnlocking, + assertResourceMutable, + assertResourceMutableUnlessUnlocking, + type FolderResourceType, + getFolderLockStatus, + getResourceLockStatus, + ResourceLockedError, +} from '@sim/platform-authz/resource-lock' +import { FolderLockedError, WorkflowLockedError } from '@sim/platform-authz/workflow' + +const RESOURCE_TYPES: FolderResourceType[] = ['workflow', 'file', 'knowledge_base', 'table'] + +describe('resource-lock engine', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + + describe.each(RESOURCE_TYPES)('%s', (resourceType) => { + it('reports unlocked when the resource and its folder chain are unlocked', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([{ locked: false, folderId: null }]) // resource row + const status = await getResourceLockStatus(resourceType, 'res-1') + expect(status).toEqual({ + locked: false, + directLocked: false, + inheritedLocked: false, + lockedBy: null, + lockedFolderId: null, + }) + await expect(assertResourceMutable(resourceType, 'res-1')).resolves.toBeUndefined() + }) + + it('reports a direct lock on the resource itself', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([{ locked: true, folderId: 'folder-1' }]) + const status = await getResourceLockStatus(resourceType, 'res-1') + expect(status).toMatchObject({ + locked: true, + directLocked: true, + inheritedLocked: false, + lockedBy: 'resource', + }) + dbChainMockFns.limit.mockResolvedValueOnce([{ locked: true, folderId: 'folder-1' }]) + await expect(assertResourceMutable(resourceType, 'res-1')).rejects.toThrow( + ResourceLockedError + ) + }) + + it('reports a lock inherited from a containing folder', async () => { + dbChainMockFns.limit + .mockResolvedValueOnce([{ locked: false, folderId: 'folder-1' }]) // resource row + .mockResolvedValueOnce([{ id: 'folder-1', parentId: null, locked: true }]) // folder row + const status = await getResourceLockStatus(resourceType, 'res-1') + expect(status).toMatchObject({ + locked: true, + lockedBy: 'folder', + lockedFolderId: 'folder-1', + }) + + dbChainMockFns.limit + .mockResolvedValueOnce([{ locked: false, folderId: 'folder-1' }]) + .mockResolvedValueOnce([{ id: 'folder-1', parentId: null, locked: true }]) + let error: unknown + try { + await assertResourceMutable(resourceType, 'res-1') + } catch (e) { + error = e + } + expect(error).toBeInstanceOf(ResourceLockedError) + expect((error as ResourceLockedError).inherited).toBe(true) + }) + + it('assertFolderMutable throws for a locked folder', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([{ id: 'folder-1', parentId: null, locked: true }]) + await expect(assertFolderMutable('folder-1', resourceType)).rejects.toThrow( + ResourceLockedError + ) + }) + + it('assertFolderMutable no-ops for a null folderId', async () => { + await expect(assertFolderMutable(null, resourceType)).resolves.toBeUndefined() + expect(dbChainMockFns.select).not.toHaveBeenCalled() + }) + + it('getFolderLockStatus returns unlocked once the row is not found', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([]) + const status = await getFolderLockStatus('missing-folder', resourceType) + expect(status.locked).toBe(false) + }) + + it('getFolderLockStatus keeps walking past a direct lock and reports a locked ancestor, not the direct lock', async () => { + dbChainMockFns.limit + .mockResolvedValueOnce([{ id: 'folder-1', parentId: 'folder-2', locked: true }]) + .mockResolvedValueOnce([{ id: 'folder-2', parentId: null, locked: true }]) + const status = await getFolderLockStatus('folder-1', resourceType) + expect(status).toMatchObject({ + locked: true, + directLocked: false, + inheritedLocked: true, + lockedBy: 'folder', + lockedFolderId: 'folder-2', + }) + }) + + it('getFolderLockStatus reports the direct lock when no ancestor is locked', async () => { + dbChainMockFns.limit + .mockResolvedValueOnce([{ id: 'folder-1', parentId: 'folder-2', locked: true }]) + .mockResolvedValueOnce([{ id: 'folder-2', parentId: null, locked: false }]) + const status = await getFolderLockStatus('folder-1', resourceType) + expect(status).toMatchObject({ + locked: true, + directLocked: true, + inheritedLocked: false, + lockedBy: 'folder', + lockedFolderId: 'folder-1', + }) + }) + + it('assertFolderMutableUnlessUnlocking bypasses only a direct lock on the target itself', async () => { + dbChainMockFns.limit + .mockResolvedValueOnce([{ id: 'folder-1', parentId: 'folder-2', locked: true }]) + .mockResolvedValueOnce([{ id: 'folder-2', parentId: null, locked: false }]) + await expect( + assertFolderMutableUnlessUnlocking('folder-1', resourceType, true) + ).resolves.toBeUndefined() + }) + + it('assertFolderMutableUnlessUnlocking still blocks on a locked ancestor even while bypassing the direct lock', async () => { + dbChainMockFns.limit + .mockResolvedValueOnce([{ id: 'folder-1', parentId: 'folder-2', locked: true }]) + .mockResolvedValueOnce([{ id: 'folder-2', parentId: null, locked: true }]) + await expect( + assertFolderMutableUnlessUnlocking('folder-1', resourceType, true) + ).rejects.toThrow(ResourceLockedError) + }) + + it('assertFolderMutableUnlessUnlocking still throws when not unlocking', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([{ id: 'folder-1', parentId: null, locked: true }]) + await expect( + assertFolderMutableUnlessUnlocking('folder-1', resourceType, false) + ).rejects.toThrow(ResourceLockedError) + }) + + it('getResourceLockStatus reports a folder-inherited lock even when the resource itself is also directly locked', async () => { + dbChainMockFns.limit + .mockResolvedValueOnce([{ locked: true, folderId: 'folder-1' }]) // resource row (also directly locked) + .mockResolvedValueOnce([{ id: 'folder-1', parentId: null, locked: true }]) // folder row + const status = await getResourceLockStatus(resourceType, 'res-1') + expect(status).toMatchObject({ + locked: true, + directLocked: false, + inheritedLocked: true, + lockedBy: 'folder', + lockedFolderId: 'folder-1', + }) + }) + + it('assertResourceMutableUnlessUnlocking still blocks on a locked folder even while bypassing a direct resource lock', async () => { + dbChainMockFns.limit + .mockResolvedValueOnce([{ locked: true, folderId: 'folder-1' }]) + .mockResolvedValueOnce([{ id: 'folder-1', parentId: null, locked: true }]) + await expect( + assertResourceMutableUnlessUnlocking(resourceType, 'res-1', true) + ).rejects.toThrow(ResourceLockedError) + }) + + it('assertResourceMutableUnlessUnlocking bypasses a direct resource lock when its folder chain is unlocked', async () => { + dbChainMockFns.limit + .mockResolvedValueOnce([{ locked: true, folderId: 'folder-1' }]) + .mockResolvedValueOnce([{ id: 'folder-1', parentId: null, locked: false }]) + await expect( + assertResourceMutableUnlessUnlocking(resourceType, 'res-1', true) + ).resolves.toBeUndefined() + }) + }) +}) + +describe('WorkflowLockedError / FolderLockedError subclass regression', () => { + it('WorkflowLockedError is still an instanceof itself and of the generic ResourceLockedError', () => { + const error = new WorkflowLockedError('Workflow is locked') + expect(error).toBeInstanceOf(WorkflowLockedError) + expect(error).toBeInstanceOf(ResourceLockedError) + expect(error.status).toBe(423) + expect(error.message).toBe('Workflow is locked') + }) + + it('FolderLockedError is still an instanceof itself and of the generic ResourceLockedError', () => { + const error = new FolderLockedError('Folder is locked') + expect(error).toBeInstanceOf(FolderLockedError) + expect(error).toBeInstanceOf(ResourceLockedError) + expect(error.status).toBe(423) + expect(error.message).toBe('Folder is locked') + }) +}) diff --git a/apps/sim/lib/folders/subtree.ts b/apps/sim/lib/folders/subtree.ts new file mode 100644 index 00000000000..eb5a18665c4 --- /dev/null +++ b/apps/sim/lib/folders/subtree.ts @@ -0,0 +1,45 @@ +/** + * Pure subtree-walk algorithm shared by every folder cascade (generic + * `folder` table cascades in `orchestration.ts`, and the file-folder-specific + * cascades in `uploads/contexts/workspace/workspace-file-folder-manager.ts`). + * Kept dependency-free (no `db` import) so both call sites — one of which is + * imported *by* `orchestration.ts` — can share it without a circular import. + */ + +/** Minimal shape required to walk a `parentId` chain. */ +export interface FolderSubtreeRow { + id: string + parentId: string | null +} + +/** + * Given a flat list of folders and a root id, returns every descendant id + * (NOT including the root itself) reachable by walking `parentId` links. + * Guards against cycles via a `seen` set. + */ +export function collectDescendantFolderIds( + folders: T[], + rootId: string +): string[] { + const childrenByParent = new Map() + for (const folder of folders) { + if (!folder.parentId) continue + const children = childrenByParent.get(folder.parentId) ?? [] + children.push(folder.id) + childrenByParent.set(folder.parentId, children) + } + + const descendants: string[] = [] + const seen = new Set([rootId]) + const visit = (id: string) => { + for (const childId of childrenByParent.get(id) ?? []) { + if (seen.has(childId)) continue + seen.add(childId) + descendants.push(childId) + visit(childId) + } + } + visit(rootId) + + return descendants +} diff --git a/apps/sim/lib/folders/tree.ts b/apps/sim/lib/folders/tree.ts index 41196a7e35b..39970fbf364 100644 --- a/apps/sim/lib/folders/tree.ts +++ b/apps/sim/lib/folders/tree.ts @@ -1,11 +1,11 @@ -import type { FolderTreeNode, WorkflowFolder } from '@/stores/folders/types' +import type { Folder, FolderTreeNode } from '@/stores/folders/types' -export function buildFolderMap(folders: WorkflowFolder[]): Record { +export function buildFolderMap(folders: Folder[]): Record { return Object.fromEntries(folders.map((folder) => [folder.id, folder])) } export function buildFolderTree( - folders: Record, + folders: Record, workspaceId: string ): FolderTreeNode[] { const workspaceFolders = Object.values(folders).filter( @@ -27,30 +27,27 @@ export function buildFolderTree( } export function getFolderById( - folders: Record, + folders: Record, folderId: string -): WorkflowFolder | undefined { +): Folder | undefined { return folders[folderId] } export function getChildFolders( - folders: Record, + folders: Record, parentId: string | null -): WorkflowFolder[] { +): Folder[] { return Object.values(folders) .filter((folder) => folder.parentId === parentId) .sort((a, b) => a.sortOrder - b.sortOrder || a.name.localeCompare(b.name)) } -export function getFolderPath( - folders: Record, - folderId: string -): WorkflowFolder[] { - const path: WorkflowFolder[] = [] +export function getFolderPath(folders: Record, folderId: string): Folder[] { + const path: Folder[] = [] let currentId: string | null = folderId while (currentId && folders[currentId]) { - const folder: WorkflowFolder = folders[currentId] + const folder: Folder = folders[currentId] path.unshift(folder) currentId = folder.parentId } diff --git a/apps/sim/lib/knowledge/orchestration/index.test.ts b/apps/sim/lib/knowledge/orchestration/index.test.ts new file mode 100644 index 00000000000..2bdabd72dae --- /dev/null +++ b/apps/sim/lib/knowledge/orchestration/index.test.ts @@ -0,0 +1,82 @@ +/** + * @vitest-environment node + * + * `restoreKnowledgeBase` calls `assertResourceMutable('knowledge_base', id)` before + * restoring -- this correctly evaluates both the KB's own `locked` flag and its + * (unchanged on restore) containing folder chain, since restore doesn't change + * folderId. Guards that a `ResourceLockedError` thrown from `restoreKnowledgeBase` + * surfaces through `performRestoreKnowledgeBase` as `errorCode: 'locked'` (423). + */ +import { ResourceLockedError } from '@sim/platform-authz/resource-lock' +import { auditMock, dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockRestoreKnowledgeBase } = vi.hoisted(() => ({ + mockRestoreKnowledgeBase: vi.fn(), +})) + +vi.mock('@sim/db', () => dbChainMock) +vi.mock('@sim/audit', () => auditMock) + +vi.mock('@/lib/knowledge/service', () => { + class KnowledgeBaseConflictErrorStub extends Error {} + return { + restoreKnowledgeBase: mockRestoreKnowledgeBase, + KnowledgeBaseConflictError: KnowledgeBaseConflictErrorStub, + } +}) + +import { performRestoreKnowledgeBase } from '@/lib/knowledge/orchestration' + +describe('performRestoreKnowledgeBase — resource-lock enforcement', () => { + beforeEach(() => { + vi.clearAllMocks() + dbChainMockFns.limit.mockReset() + resetDbChainMock() + dbChainMockFns.limit.mockResolvedValue([ + { id: 'kb-1', name: 'KB 1', workspaceId: 'ws-1', userId: 'user-1' }, + ]) + }) + + it('returns a 423 (locked) when the knowledge base itself is directly locked', async () => { + mockRestoreKnowledgeBase.mockRejectedValueOnce( + new ResourceLockedError('knowledge_base', false, 'Knowledge base is locked') + ) + + const result = await performRestoreKnowledgeBase({ + knowledgeBaseId: 'kb-1', + userId: 'user-1', + }) + + expect(result).toMatchObject({ success: false, errorCode: 'locked' }) + }) + + it('returns a 423 (locked, inherited) when the knowledge base is restored into a folder that is now locked', async () => { + mockRestoreKnowledgeBase.mockRejectedValueOnce( + new ResourceLockedError( + 'knowledge_base', + true, + 'Knowledge base is locked by its containing folder' + ) + ) + + const result = await performRestoreKnowledgeBase({ + knowledgeBaseId: 'kb-1', + userId: 'user-1', + }) + + expect(result).toMatchObject({ success: false, errorCode: 'locked' }) + }) + + it('restores successfully when unlocked', async () => { + mockRestoreKnowledgeBase.mockResolvedValueOnce(undefined) + + const result = await performRestoreKnowledgeBase({ + knowledgeBaseId: 'kb-1', + userId: 'user-1', + }) + + expect(result.success).toBe(true) + expect(mockRestoreKnowledgeBase).toHaveBeenCalledWith('kb-1', expect.any(String)) + }) +}) diff --git a/apps/sim/lib/knowledge/orchestration/index.ts b/apps/sim/lib/knowledge/orchestration/index.ts index dc44ac10ee9..15fc6582911 100644 --- a/apps/sim/lib/knowledge/orchestration/index.ts +++ b/apps/sim/lib/knowledge/orchestration/index.ts @@ -2,6 +2,7 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { knowledgeBase } from '@sim/db/schema' import { createLogger } from '@sim/logger' +import { ResourceLockedError } from '@sim/platform-authz/resource-lock' import { toError } from '@sim/utils/errors' import { eq } from 'drizzle-orm' import { generateRequestId } from '@/lib/core/utils/request' @@ -9,7 +10,7 @@ import { KnowledgeBaseConflictError, restoreKnowledgeBase } from '@/lib/knowledg const logger = createLogger('KnowledgeBaseOrchestration') -export type KnowledgeOrchestrationErrorCode = 'not_found' | 'conflict' | 'internal' +export type KnowledgeOrchestrationErrorCode = 'not_found' | 'conflict' | 'locked' | 'internal' export interface RestorableKnowledgeBase { id: string @@ -83,6 +84,9 @@ export async function performRestoreKnowledgeBase( if (error instanceof KnowledgeBaseConflictError) { return { success: false, error: error.message, errorCode: 'conflict' } } + if (error instanceof ResourceLockedError) { + return { success: false, error: error.message, errorCode: 'locked' } + } return { success: false, error: toError(error).message, errorCode: 'internal' } } } diff --git a/apps/sim/lib/knowledge/service.test.ts b/apps/sim/lib/knowledge/service.test.ts index be1fff5ba87..c55dbd65109 100644 --- a/apps/sim/lib/knowledge/service.test.ts +++ b/apps/sim/lib/knowledge/service.test.ts @@ -10,8 +10,56 @@ import { } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' +/** Minimal stand-in for `@sim/platform-authz/resource-lock`'s `ResourceLockedError` + * (423, carries `resourceType`/`inherited`) — avoids `vi.importActual`. */ +const { + mockAssertResourceMutable, + mockAssertFolderMutable, + mockAssertResourceMutableUnlessUnlocking, + MockResourceLockedError, +} = vi.hoisted(() => { + class ResourceLockedErrorStub extends Error { + readonly status = 423 + readonly resourceType: string + readonly inherited: boolean + constructor(resourceType: string, inherited: boolean, message?: string) { + super(message ?? `${resourceType} is locked`) + this.name = 'ResourceLockedError' + this.resourceType = resourceType + this.inherited = inherited + } + } + const assertResourceMutable = vi.fn() + // Real wrapper logic (not a bare passthrough) so tests that configure + // assertResourceMutable to reject with a direct vs. inherited error see the + // same "unless unlocking" behavior the production wrapper implements. + const assertResourceMutableUnlessUnlocking = vi.fn( + async (resourceType: string, resourceId: string, unlocking: boolean, tx?: unknown) => { + try { + const args = [resourceType, resourceId, tx].filter((a) => a !== undefined) + await assertResourceMutable(...args) + } catch (error) { + if (unlocking && error instanceof ResourceLockedErrorStub && !error.inherited) return + throw error + } + } + ) + return { + mockAssertResourceMutable: assertResourceMutable, + mockAssertFolderMutable: vi.fn(), + mockAssertResourceMutableUnlessUnlocking: assertResourceMutableUnlessUnlocking, + MockResourceLockedError: ResourceLockedErrorStub, + } +}) + vi.mock('@sim/db', () => dbChainMock) vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock) +vi.mock('@sim/platform-authz/resource-lock', () => ({ + assertResourceMutable: mockAssertResourceMutable, + assertFolderMutable: mockAssertFolderMutable, + assertResourceMutableUnlessUnlocking: mockAssertResourceMutableUnlessUnlocking, + ResourceLockedError: MockResourceLockedError, +})) import { KnowledgeBasePermissionError, updateKnowledgeBase } from '@/lib/knowledge/service' @@ -200,3 +248,151 @@ describe('updateKnowledgeBase — file ownership binding re-point on workspace c expect(dbChainMockFns.update).toHaveBeenCalledTimes(1) }) }) + +describe('updateKnowledgeBase — resource-lock enforcement', () => { + beforeEach(() => { + vi.clearAllMocks() + dbChainMockFns.limit.mockReset() + resetDbChainMock() + mockAssertResourceMutable.mockReset() + mockAssertFolderMutable.mockReset() + }) + + it('rejects a non-lock update on a directly-locked knowledge base with a 423', async () => { + mockAssertResourceMutable.mockRejectedValueOnce( + new MockResourceLockedError('knowledge_base', false, 'Knowledge base is locked') + ) + + await expect(updateKnowledgeBase('kb-1', { name: 'Renamed' }, 'req-1')).rejects.toMatchObject({ + status: 423, + inherited: false, + }) + + expect(mockAssertResourceMutable).toHaveBeenCalledWith('knowledge_base', 'kb-1') + expect(dbChainMockFns.update).not.toHaveBeenCalled() + }) + + it('rejects a non-lock update when the knowledge base is inside a locked folder with a 423', async () => { + mockAssertResourceMutable.mockRejectedValueOnce( + new MockResourceLockedError( + 'knowledge_base', + true, + 'Knowledge base is locked by its containing folder' + ) + ) + + await expect( + updateKnowledgeBase('kb-1', { description: 'updated' }, 'req-1') + ).rejects.toMatchObject({ + status: 423, + inherited: true, + }) + + expect(dbChainMockFns.update).not.toHaveBeenCalled() + }) + + it('skips the lock check for a lock-only update (unlocking a directly-locked KB)', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([{ workspaceId: 'ws-current', userId: 'u-1' }]) + + await updateKnowledgeBase('kb-1', { locked: false }, 'req-1').catch(() => undefined) + + expect(mockAssertResourceMutable).not.toHaveBeenCalled() + }) + + it('skips the lock check when unlocking via the route-shaped object (all keys present, unset ones undefined)', async () => { + // Regression test: apps/sim/app/api/knowledge/[id]/route.ts always builds a full + // literal object (`{ name: validatedData.name, ..., locked: validatedData.locked }`) + // rather than spreading only the fields the client actually sent. `Object.keys()` + // includes keys whose value is `undefined`, so a naive `Object.keys(updates).some(...)` + // check always sees every field as "provided" and can never detect a lock-only + // update — permanently blocking unlock, since the mutability check reads the + // still-locked current row. This calls updateKnowledgeBase with that exact shape. + dbChainMockFns.limit.mockResolvedValueOnce([{ workspaceId: 'ws-current', userId: 'u-1' }]) + + await updateKnowledgeBase( + 'kb-1', + { + name: undefined, + description: undefined, + workspaceId: undefined, + folderId: undefined, + chunkingConfig: undefined, + locked: false, + }, + 'req-1' + ).catch(() => undefined) + + expect(mockAssertResourceMutable).not.toHaveBeenCalled() + }) + + it('allows unlocking a directly-locked knowledge base combined with a move in the same request', async () => { + // Regression test: hasNonLockUpdate is true whenever folderId also changes, so a + // combined "unlock + move" request previously still ran assertResourceMutable + // against the KB's current (still-locked) state and was incorrectly rejected, + // even though the request unlocks it as part of this same atomic write. The + // fixed behavior still runs the check (so an inherited lock is caught below), + // but treats a DIRECT lock as satisfied since this request clears it. + dbChainMockFns.limit + .mockResolvedValueOnce([{ workspaceId: 'ws-current', userId: 'u-1' }]) // currentKb (FOR UPDATE) + .mockResolvedValueOnce([ + { workspaceId: 'ws-current', resourceType: 'knowledge_base', deletedAt: null }, + ]) // assertFolderParentValid's parent lookup + mockAssertResourceMutable.mockRejectedValueOnce( + new MockResourceLockedError('knowledge_base', false, 'Knowledge base is locked') + ) + + await updateKnowledgeBase('kb-1', { folderId: 'folder-1', locked: false }, 'req-1').catch( + () => undefined + ) + + expect(mockAssertResourceMutable).toHaveBeenCalledWith('knowledge_base', 'kb-1') + expect(mockAssertFolderMutable).toHaveBeenCalledWith( + 'folder-1', + 'knowledge_base', + expect.anything() + ) + }) + + it('still rejects unlocking a knowledge base combined with a move when the lock is inherited from its folder', async () => { + // Clearing the KB's own `locked` flag doesn't affect a lock inherited from its + // containing folder -- that must still block the combined request. + mockAssertResourceMutable.mockRejectedValueOnce( + new MockResourceLockedError( + 'knowledge_base', + true, + 'Knowledge base is locked by its containing folder' + ) + ) + + await expect( + updateKnowledgeBase('kb-1', { folderId: 'folder-1', locked: false }, 'req-1') + ).rejects.toMatchObject({ status: 423, inherited: true }) + }) + + it('rejects moving the knowledge base into a locked destination folder with a 423', async () => { + // Regression test: assertResourceMutable only checks the KB's *current* folder + // chain -- without a separate assertFolderMutable(updates.folderId, ...) check, + // a KB in an unlocked folder could be moved into a locked one. + dbChainMockFns.limit + .mockResolvedValueOnce([{ workspaceId: 'ws-current', userId: 'u-1' }]) // currentKb (FOR UPDATE) + .mockResolvedValueOnce([ + { workspaceId: 'ws-current', resourceType: 'knowledge_base', deletedAt: null }, + ]) // assertFolderParentValid's parent lookup + mockAssertFolderMutable.mockRejectedValueOnce( + new MockResourceLockedError('knowledge_base', false, 'Folder is locked') + ) + + await expect( + updateKnowledgeBase('kb-1', { folderId: 'folder-locked' }, 'req-1') + ).rejects.toMatchObject({ status: 423, inherited: false }) + + // Called with a 3rd arg (the `tx` client) so the recheck runs inside the same + // transaction as the write, closing the TOCTOU window between the check and write. + expect(mockAssertFolderMutable).toHaveBeenCalledWith( + 'folder-locked', + 'knowledge_base', + expect.anything() + ) + expect(dbChainMockFns.update).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/knowledge/service.ts b/apps/sim/lib/knowledge/service.ts index 084e6feb020..88ef81900e0 100644 --- a/apps/sim/lib/knowledge/service.ts +++ b/apps/sim/lib/knowledge/service.ts @@ -8,10 +8,16 @@ import { workspaceFiles, } from '@sim/db/schema' import { createLogger } from '@sim/logger' +import { + assertFolderMutable, + assertResourceMutable, + assertResourceMutableUnlessUnlocking, +} from '@sim/platform-authz/resource-lock' import { getPostgresErrorCode } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { and, count, eq, exists, inArray, isNotNull, isNull, ne, or, sql } from 'drizzle-orm' import { generateRestoreName } from '@/lib/core/utils/restore-name' +import { assertFolderParentValid } from '@/lib/folders/parent-validation' import type { ChunkingConfig, CreateKnowledgeBaseData, @@ -32,6 +38,10 @@ export class KnowledgeBasePermissionError extends Error { readonly code = 'KNOWLEDGE_BASE_FORBIDDEN' as const } +export class KnowledgeBaseValidationError extends Error { + readonly code = 'KNOWLEDGE_BASE_VALIDATION' as const +} + export type KnowledgeBaseScope = 'active' | 'archived' | 'all' /** @@ -63,6 +73,8 @@ export async function getKnowledgeBases( updatedAt: knowledgeBase.updatedAt, deletedAt: knowledgeBase.deletedAt, workspaceId: knowledgeBase.workspaceId, + folderId: knowledgeBase.folderId, + locked: knowledgeBase.locked, docCount: count(document.id), }) .from(knowledgeBase) @@ -164,39 +176,66 @@ export async function createKnowledgeBase( ) } + // Folder-parent validity (folder table) and duplicate-name (knowledgeBase + // table) checks are disjoint queries with no dependency on each other, so + // they run concurrently rather than as two sequential round-trips. + const [parentError, duplicate] = await Promise.all([ + data.folderId + ? assertFolderParentValid(data.folderId, { + workspaceId: data.workspaceId, + resourceType: 'knowledge_base', + }) + : Promise.resolve(null), + db + .select({ id: knowledgeBase.id }) + .from(knowledgeBase) + .where( + and( + eq(knowledgeBase.workspaceId, data.workspaceId), + eq(knowledgeBase.name, data.name), + isNull(knowledgeBase.deletedAt) + ) + ) + .limit(1), + ]) + + if (parentError) { + throw new KnowledgeBaseValidationError(parentError.error) + } + if (duplicate.length > 0) { + throw new KnowledgeBaseConflictError(data.name) + } + const newKnowledgeBase = { id: kbId, name: data.name, description: data.description ?? null, workspaceId: data.workspaceId, + folderId: data.folderId ?? null, userId: data.userId, tokenCount: 0, embeddingModel: data.embeddingModel, embeddingDimension: data.embeddingDimension, chunkingConfig: data.chunkingConfig, + locked: false, createdAt: now, updatedAt: now, deletedAt: null, } - const duplicate = await db - .select({ id: knowledgeBase.id }) - .from(knowledgeBase) - .where( - and( - eq(knowledgeBase.workspaceId, data.workspaceId), - eq(knowledgeBase.name, data.name), - isNull(knowledgeBase.deletedAt) - ) - ) - .limit(1) - - if (duplicate.length > 0) { - throw new KnowledgeBaseConflictError(data.name) - } - + // Wrap the lock check and insert in a transaction so a folder lock applied between + // the check and the insert can't slip a knowledge base into a now-locked folder + // (same TOCTOU class as createTable in lib/table/service.ts). try { - await db.insert(knowledgeBase).values(newKnowledgeBase) + await db.transaction(async (tx) => { + // assertFolderParentValid above only checks workspace/type/deleted-state -- without + // this, a knowledge base could be created directly inside a locked folder. Passing + // `tx` keeps this read inside the same transaction as the insert below. + if (data.folderId) { + await assertFolderMutable(data.folderId, 'knowledge_base', tx) + } + await tx.insert(knowledgeBase).values(newKnowledgeBase) + }) } catch (error: unknown) { if (getPostgresErrorCode(error) === '23505') { throw new KnowledgeBaseConflictError(data.name) @@ -219,6 +258,8 @@ export async function createKnowledgeBase( updatedAt: now, deletedAt: null, workspaceId: data.workspaceId, + folderId: data.folderId ?? null, + locked: false, docCount: 0, connectorTypes: [], } @@ -233,6 +274,8 @@ export async function updateKnowledgeBase( name?: string description?: string workspaceId?: string | null + folderId?: string | null + locked?: boolean chunkingConfig?: { maxSize: number minSize: number @@ -248,6 +291,8 @@ export async function updateKnowledgeBase( name?: string description?: string | null workspaceId?: string | null + folderId?: string | null + locked?: boolean chunkingConfig?: { maxSize: number minSize: number @@ -262,10 +307,36 @@ export async function updateKnowledgeBase( if (updates.name !== undefined) updateData.name = updates.name if (updates.description !== undefined) updateData.description = updates.description if (updates.workspaceId !== undefined) updateData.workspaceId = updates.workspaceId + if (updates.folderId !== undefined) updateData.folderId = updates.folderId + if (updates.locked !== undefined) updateData.locked = updates.locked if (updates.chunkingConfig !== undefined) { updateData.chunkingConfig = updates.chunkingConfig } + // `Object.keys(updates)` can't distinguish "field genuinely provided" from "field + // present as undefined" (the route always builds a full literal object) — reuse the + // same `!== undefined` checks that already gate `updateData` above, matching the + // isLockOnlyUpdate pattern used by renameTable()/performRenameWorkspaceFile(). + const hasNonLockUpdate = + updates.name !== undefined || + updates.description !== undefined || + updates.workspaceId !== undefined || + updates.folderId !== undefined || + updates.chunkingConfig !== undefined + // An admin combining `locked: false` with other field changes in one request is + // unlocking the knowledge base as part of this same atomic write -- the + // mutable-check must not treat that request's own current (about-to-be-cleared) + // lock as blocking. It must still enforce a lock inherited from the KB's + // containing folder, since clearing the KB's own `locked` flag doesn't affect + // that. + if (hasNonLockUpdate) { + await assertResourceMutableUnlessUnlocking( + 'knowledge_base', + knowledgeBaseId, + updates.locked === false + ) + } + if (updates.workspaceId !== undefined && !options?.actorUserId) { throw new KnowledgeBasePermissionError( 'actorUserId is required to change a knowledge base workspace' @@ -296,6 +367,21 @@ export async function updateKnowledgeBase( throw new Error(`Knowledge base ${knowledgeBaseId} not found`) } + // The `hasNonLockUpdate` check above is a separate round-trip against the + // default `db` client -- an admin could lock this KB in the window between + // that check and this transaction. Re-check inside the transaction (joining + // `tx` so the read is part of the same atomic unit as the FOR UPDATE lock + // just acquired and the write below), matching deleteKnowledgeBase/ + // restoreKnowledgeBase in this file. + if (hasNonLockUpdate) { + await assertResourceMutableUnlessUnlocking( + 'knowledge_base', + knowledgeBaseId, + updates.locked === false, + tx + ) + } + if (updates.workspaceId !== undefined) { const actorUserId = options?.actorUserId as string const currentWorkspaceId = currentKb.workspaceId ?? null @@ -319,28 +405,53 @@ export async function updateKnowledgeBase( } } - if (updates.name !== undefined) { - const effectiveWorkspaceId = - updates.workspaceId !== undefined ? updates.workspaceId : currentKb.workspaceId + const effectiveWorkspaceId = + updates.workspaceId !== undefined ? updates.workspaceId : currentKb.workspaceId - if (effectiveWorkspaceId) { - const duplicate = await tx - .select({ id: knowledgeBase.id }) - .from(knowledgeBase) - .where( - and( - eq(knowledgeBase.workspaceId, effectiveWorkspaceId), - eq(knowledgeBase.name, updates.name), - isNull(knowledgeBase.deletedAt), - ne(knowledgeBase.id, knowledgeBaseId) - ) + if (updates.folderId && !effectiveWorkspaceId) { + throw new KnowledgeBaseValidationError( + 'Cannot assign a folder to a knowledge base with no workspace' + ) + } + + // Folder-parent validity and duplicate-name are disjoint queries with + // no dependency on each other; run them concurrently on the `tx` + // client to minimize the window the FOR UPDATE row lock is held. + const [parentError, duplicate] = await Promise.all([ + updates.folderId + ? assertFolderParentValid( + updates.folderId, + { workspaceId: effectiveWorkspaceId as string, resourceType: 'knowledge_base' }, + tx ) - .limit(1) + : Promise.resolve(null), + updates.name !== undefined && effectiveWorkspaceId + ? tx + .select({ id: knowledgeBase.id }) + .from(knowledgeBase) + .where( + and( + eq(knowledgeBase.workspaceId, effectiveWorkspaceId), + eq(knowledgeBase.name, updates.name), + isNull(knowledgeBase.deletedAt), + ne(knowledgeBase.id, knowledgeBaseId) + ) + ) + .limit(1) + : Promise.resolve([]), + ]) - if (duplicate.length > 0) { - throw new KnowledgeBaseConflictError(updates.name) - } - } + if (parentError) { + throw new KnowledgeBaseValidationError(parentError.error) + } + // assertResourceMutable above only checked the KB's *current* folder chain — + // without this, a KB could be moved out of an unlocked folder into a locked one. + // Passing `tx` keeps this read inside the same transaction as the write below. + if (updates.folderId) { + await assertFolderMutable(updates.folderId, 'knowledge_base', tx) + } + if (updates.name !== undefined && effectiveWorkspaceId && duplicate.length > 0) { + throw new KnowledgeBaseConflictError(updates.name) } await tx @@ -407,6 +518,8 @@ export async function updateKnowledgeBase( updatedAt: knowledgeBase.updatedAt, deletedAt: knowledgeBase.deletedAt, workspaceId: knowledgeBase.workspaceId, + folderId: knowledgeBase.folderId, + locked: knowledgeBase.locked, docCount: count(document.id), }) .from(knowledgeBase) @@ -457,6 +570,8 @@ export async function getKnowledgeBaseById( updatedAt: knowledgeBase.updatedAt, deletedAt: knowledgeBase.deletedAt, workspaceId: knowledgeBase.workspaceId, + folderId: knowledgeBase.folderId, + locked: knowledgeBase.locked, docCount: count(document.id), }) .from(knowledgeBase) @@ -492,11 +607,19 @@ export async function deleteKnowledgeBase( knowledgeBaseId: string, requestId: string ): Promise { + await assertResourceMutable('knowledge_base', knowledgeBaseId) + const now = new Date() await db.transaction(async (tx) => { await tx.execute(sql`SELECT 1 FROM knowledge_base WHERE id = ${knowledgeBaseId} FOR UPDATE`) + // The pre-check above is a separate round-trip -- an admin could lock this KB + // in the window between that check and this transaction. Re-check inside the + // transaction, joining `tx` so the read is part of the same atomic unit as + // the FOR UPDATE lock and the writes below. + await assertResourceMutable('knowledge_base', knowledgeBaseId, tx) + await tx .update(knowledgeBase) .set({ @@ -587,6 +710,12 @@ export async function restoreKnowledgeBase( await db.transaction(async (tx) => { await tx.execute(sql`SELECT 1 FROM knowledge_base WHERE id = ${knowledgeBaseId} FOR UPDATE`) + // Restore doesn't change folderId, so this correctly evaluates both the KB's own + // `locked` flag and its (unchanged) containing folder chain. Runs inside the same + // transaction as the FOR UPDATE lock above and the write below, closing the TOCTOU + // window where a concurrent request locks the KB or its folder in between. + await assertResourceMutable('knowledge_base', knowledgeBaseId, tx) + attemptedRestoreName = await generateRestoreName(kb.name, async (candidate) => { if (!kb.workspaceId) return false const [match] = await tx diff --git a/apps/sim/lib/knowledge/types.ts b/apps/sim/lib/knowledge/types.ts index 3a729f0981c..06a1005f926 100644 --- a/apps/sim/lib/knowledge/types.ts +++ b/apps/sim/lib/knowledge/types.ts @@ -26,6 +26,8 @@ export interface KnowledgeBaseWithCounts { updatedAt: Date deletedAt: Date | null workspaceId: string | null + folderId: string | null + locked: boolean docCount: number connectorTypes: string[] } @@ -38,6 +40,7 @@ export interface CreateKnowledgeBaseData { embeddingDimension: 1536 chunkingConfig: ChunkingConfig userId: string + folderId?: string | null } export interface TagDefinition { @@ -114,6 +117,8 @@ export interface KnowledgeBaseData { updatedAt: string deletedAt: string | null workspaceId: string | null + folderId: string | null + locked: boolean docCount?: number connectorTypes?: string[] } diff --git a/apps/sim/lib/logs/folder-expansion.ts b/apps/sim/lib/logs/folder-expansion.ts index 1ac5c599a70..2e132eefa6a 100644 --- a/apps/sim/lib/logs/folder-expansion.ts +++ b/apps/sim/lib/logs/folder-expansion.ts @@ -1,5 +1,5 @@ import { db } from '@sim/db' -import { workflowFolder } from '@sim/db/schema' +import { folder } from '@sim/db/schema' import { and, eq, isNull } from 'drizzle-orm' /** @@ -23,9 +23,15 @@ export async function expandFolderIdsWithDescendants( if (seedIds.length === 0) return folderIdsCsv const rows = await db - .select({ id: workflowFolder.id, parentId: workflowFolder.parentId }) - .from(workflowFolder) - .where(and(eq(workflowFolder.workspaceId, workspaceId), isNull(workflowFolder.archivedAt))) + .select({ id: folder.id, parentId: folder.parentId }) + .from(folder) + .where( + and( + eq(folder.workspaceId, workspaceId), + eq(folder.resourceType, 'workflow'), + isNull(folder.deletedAt) + ) + ) const childrenByParent = new Map() for (const row of rows) { diff --git a/apps/sim/lib/resources/orchestration/restore-resource.ts b/apps/sim/lib/resources/orchestration/restore-resource.ts index de305bdd606..d4a382abf74 100644 --- a/apps/sim/lib/resources/orchestration/restore-resource.ts +++ b/apps/sim/lib/resources/orchestration/restore-resource.ts @@ -3,6 +3,7 @@ import { toError } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import type { MothershipResource } from '@/lib/copilot/resources/types' import type { ToolExecutionResult } from '@/lib/copilot/tool-executor/types' +import { performRestoreFolder as performRestoreGenericFolder } from '@/lib/folders/orchestration' import { getRestorableKnowledgeBase, performRestoreKnowledgeBase, @@ -27,6 +28,8 @@ export type RestorableResourceType = | 'knowledgebase' | 'folder' | 'file_folder' + | 'table_folder' + | 'knowledge_base_folder' export interface PerformRestoreResourceParams { type: RestorableResourceType @@ -171,6 +174,29 @@ export async function performRestoreResource( restoredItems: result.restoredItems, }) } + + case 'table_folder': + case 'knowledge_base_folder': { + if (!(await hasWriteAccess(userId, workspaceId, workspaceId))) { + return { success: false, error: 'Folder not found' } + } + + const result = await performRestoreGenericFolder({ + resourceType: type === 'table_folder' ? 'table' : 'knowledge_base', + folderId: id, + workspaceId, + userId, + }) + if (!result.success) { + return { success: false, error: result.error || 'Failed to restore folder' } + } + + logger.info(`${type} restored via restore_resource`, { + folderId: id, + restoredItems: result.restoredItems, + }) + return success({ type, id, restoredItems: result.restoredItems }) + } } } catch (error) { logger.error('Failed to restore resource via restore_resource', { type, id, error }) diff --git a/apps/sim/lib/table/__tests__/service.test.ts b/apps/sim/lib/table/__tests__/service.test.ts new file mode 100644 index 00000000000..948d8677483 --- /dev/null +++ b/apps/sim/lib/table/__tests__/service.test.ts @@ -0,0 +1,219 @@ +/** + * @vitest-environment node + * + * Resource-lock enforcement for `renameTable` / `deleteTable`. Both call + * `assertResourceMutable('table', tableId)` before mutating — this guards a + * direct table lock (423, `inherited: false`) and a lock inherited from the + * table's containing folder (423, `inherited: true`) surface correctly, and + * that an unrelated-field update still goes through the lock check (only a + * lock-only `renameTable` call skips it). + */ +import { ResourceLockedError } from '@sim/platform-authz/resource-lock' +import { + auditMock, + dbChainMock, + dbChainMockFns, + resetDbChainMock, + resourceLockMockFns, +} from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@sim/db', () => dbChainMock) +vi.mock('@sim/audit', () => auditMock) + +import { deleteTable, renameTable } from '@/lib/table/service' + +describe('table service — resource-lock enforcement', () => { + beforeEach(() => { + vi.clearAllMocks() + dbChainMockFns.limit.mockReset() + resetDbChainMock() + resourceLockMockFns.mockAssertResourceMutable.mockReset() + resourceLockMockFns.mockAssertResourceMutable.mockResolvedValue(undefined) + }) + + describe('renameTable', () => { + it('rejects renaming a directly-locked table with a 423', async () => { + resourceLockMockFns.mockAssertResourceMutable.mockRejectedValueOnce( + new ResourceLockedError('table', false, 'Table is locked') + ) + + await expect(renameTable('tbl-1', 'new_name', 'req-1')).rejects.toMatchObject({ + status: 423, + inherited: false, + }) + + expect(resourceLockMockFns.mockAssertResourceMutable).toHaveBeenCalledWith( + 'table', + 'tbl-1', + expect.anything() + ) + expect(dbChainMockFns.update).not.toHaveBeenCalled() + }) + + it('rejects renaming a table inside a locked folder with a 423 (inherited)', async () => { + resourceLockMockFns.mockAssertResourceMutable.mockRejectedValueOnce( + new ResourceLockedError('table', true, 'Table is locked by its containing folder') + ) + + await expect(renameTable('tbl-1', 'new_name', 'req-1')).rejects.toMatchObject({ + status: 423, + inherited: true, + }) + + expect(dbChainMockFns.update).not.toHaveBeenCalled() + }) + + it('enforces the lock check on an unrelated (non-lock) rename update', async () => { + dbChainMockFns.returning.mockResolvedValueOnce([ + { + id: 'tbl-1', + createdBy: 'user-1', + workspaceId: 'ws-1', + folderId: null, + locked: false, + }, + ]) + + await renameTable('tbl-1', 'new_name', 'req-1') + + expect(resourceLockMockFns.mockAssertResourceMutable).toHaveBeenCalledWith( + 'table', + 'tbl-1', + expect.anything() + ) + expect(dbChainMockFns.update).toHaveBeenCalledTimes(1) + }) + + it('skips the lock check for a lock-only update (unlocking a directly-locked table)', async () => { + dbChainMockFns.returning.mockResolvedValueOnce([ + { + id: 'tbl-1', + createdBy: 'user-1', + workspaceId: 'ws-1', + folderId: null, + locked: false, + }, + ]) + + await renameTable('tbl-1', 'same_name', 'req-1', undefined, undefined, false, true) + + expect(resourceLockMockFns.mockAssertResourceMutable).not.toHaveBeenCalled() + }) + + it('allows unlocking a directly-locked table combined with a move in the same request', async () => { + // Regression test: isLockOnlyUpdate is false whenever folderId also changes, so a + // combined "unlock + move" request previously still ran assertResourceMutable + // against the table's current (still-locked) state and was incorrectly rejected, + // even though the request unlocks it as part of this same atomic write. The + // fixed behavior still runs the check (so an inherited lock is caught below), + // but treats a DIRECT lock as satisfied since this request clears it. + dbChainMockFns.limit + .mockResolvedValueOnce([{ workspaceId: 'ws-1' }]) // tableRow lookup + .mockResolvedValueOnce([{ workspaceId: 'ws-1', resourceType: 'table', deletedAt: null }]) // assertFolderParentValid's parent lookup + dbChainMockFns.returning.mockResolvedValueOnce([ + { + id: 'tbl-1', + createdBy: 'user-1', + workspaceId: 'ws-1', + folderId: 'folder-1', + locked: false, + }, + ]) + resourceLockMockFns.mockAssertResourceMutable.mockRejectedValueOnce( + new ResourceLockedError('table', false, 'Table is locked') + ) + + await renameTable('tbl-1', 'new_name', 'req-1', undefined, 'folder-1', false, false) + + expect(resourceLockMockFns.mockAssertResourceMutable).toHaveBeenCalledWith( + 'table', + 'tbl-1', + expect.anything() + ) + expect(resourceLockMockFns.mockAssertFolderMutable).toHaveBeenCalledWith( + 'folder-1', + 'table', + expect.anything() + ) + expect(dbChainMockFns.update).toHaveBeenCalledTimes(1) + }) + + it('still rejects unlocking a table combined with a move when the lock is inherited from its folder', async () => { + // Clearing the table's own `locked` flag doesn't affect a lock inherited from + // its containing folder -- that must still block the combined request. + resourceLockMockFns.mockAssertResourceMutable.mockRejectedValueOnce( + new ResourceLockedError('table', true, 'Table is locked by its containing folder') + ) + + await expect( + renameTable('tbl-1', 'new_name', 'req-1', undefined, 'folder-1', false, false) + ).rejects.toMatchObject({ status: 423, inherited: true }) + + expect(dbChainMockFns.update).not.toHaveBeenCalled() + }) + + it('rejects moving the table into a locked destination folder with a 423', async () => { + // Regression test: assertResourceMutable only checks the table's *current* + // folder chain -- without a separate assertFolderMutable(folderId, ...) check, + // a table in an unlocked folder could be moved into a locked one. + dbChainMockFns.limit + .mockResolvedValueOnce([{ workspaceId: 'ws-1' }]) // tableRow lookup + .mockResolvedValueOnce([{ workspaceId: 'ws-1', resourceType: 'table', deletedAt: null }]) // assertFolderParentValid's parent lookup + resourceLockMockFns.mockAssertFolderMutable.mockRejectedValueOnce( + new ResourceLockedError('table', false, 'Folder is locked') + ) + + await expect( + renameTable('tbl-1', 'new_name', 'req-1', undefined, 'folder-locked') + ).rejects.toMatchObject({ status: 423, inherited: false }) + + expect(resourceLockMockFns.mockAssertFolderMutable).toHaveBeenCalledWith( + 'folder-locked', + 'table', + expect.anything() + ) + expect(dbChainMockFns.update).not.toHaveBeenCalled() + }) + }) + + describe('deleteTable', () => { + it('rejects deleting a directly-locked table with a 423', async () => { + resourceLockMockFns.mockAssertResourceMutable.mockRejectedValueOnce( + new ResourceLockedError('table', false, 'Table is locked') + ) + + await expect(deleteTable('tbl-1', 'req-1')).rejects.toMatchObject({ + status: 423, + inherited: false, + }) + + expect(resourceLockMockFns.mockAssertResourceMutable).toHaveBeenCalledWith('table', 'tbl-1') + expect(dbChainMockFns.update).not.toHaveBeenCalled() + }) + + it('rejects deleting a table inside a locked folder with a 423 (inherited)', async () => { + resourceLockMockFns.mockAssertResourceMutable.mockRejectedValueOnce( + new ResourceLockedError('table', true, 'Table is locked by its containing folder') + ) + + await expect(deleteTable('tbl-1', 'req-1')).rejects.toMatchObject({ + status: 423, + inherited: true, + }) + + expect(dbChainMockFns.update).not.toHaveBeenCalled() + }) + + it('enforces the lock check on a normal (non-lock-related) delete', async () => { + dbChainMockFns.returning.mockResolvedValueOnce([ + { createdBy: 'user-1', workspaceId: 'ws-1', name: 'People' }, + ]) + + await deleteTable('tbl-1', 'req-1') + + expect(resourceLockMockFns.mockAssertResourceMutable).toHaveBeenCalledWith('table', 'tbl-1') + expect(dbChainMockFns.update).toHaveBeenCalledTimes(1) + }) + }) +}) diff --git a/apps/sim/lib/table/orchestration/index.test.ts b/apps/sim/lib/table/orchestration/index.test.ts new file mode 100644 index 00000000000..1ecb1c03b33 --- /dev/null +++ b/apps/sim/lib/table/orchestration/index.test.ts @@ -0,0 +1,69 @@ +/** + * @vitest-environment node + * + * `restoreTable` calls `assertResourceMutable('table', tableId)` before restoring -- + * this correctly evaluates both the table's own `locked` flag and its (unchanged on + * restore) containing folder chain, since restore doesn't change folderId. Guards + * that a `ResourceLockedError` thrown from `restoreTable` surfaces through + * `performRestoreTable` as `errorCode: 'locked'` (423). + */ +import { ResourceLockedError } from '@sim/platform-authz/resource-lock' +import { auditMock } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockGetTableById, mockRestoreTable } = vi.hoisted(() => ({ + mockGetTableById: vi.fn(), + mockRestoreTable: vi.fn(), +})) + +vi.mock('@sim/audit', () => auditMock) + +vi.mock('@/lib/table/service', () => { + class TableConflictErrorStub extends Error {} + return { + getTableById: mockGetTableById, + restoreTable: mockRestoreTable, + TableConflictError: TableConflictErrorStub, + } +}) + +import { performRestoreTable } from '@/lib/table/orchestration' + +describe('performRestoreTable — resource-lock enforcement', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetTableById.mockResolvedValue({ id: 'tbl-1', name: 'Table 1', workspaceId: 'ws-1' }) + }) + + it('returns a 423 (locked) when the table itself is directly locked', async () => { + mockRestoreTable.mockRejectedValueOnce( + new ResourceLockedError('table', false, 'Table is locked') + ) + + const result = await performRestoreTable({ tableId: 'tbl-1', userId: 'user-1' }) + + expect(result).toMatchObject({ success: false, errorCode: 'locked' }) + }) + + it('returns a 423 (locked, inherited) when the table is restored into a folder that is now locked', async () => { + mockRestoreTable.mockRejectedValueOnce( + new ResourceLockedError('table', true, 'Table is locked by its containing folder') + ) + + const result = await performRestoreTable({ tableId: 'tbl-1', userId: 'user-1' }) + + expect(result).toMatchObject({ success: false, errorCode: 'locked' }) + }) + + it('restores successfully when unlocked', async () => { + mockRestoreTable.mockResolvedValueOnce(undefined) + mockGetTableById + .mockResolvedValueOnce({ id: 'tbl-1', name: 'Table 1', workspaceId: 'ws-1' }) + .mockResolvedValueOnce({ id: 'tbl-1', name: 'Table 1', workspaceId: 'ws-1' }) + + const result = await performRestoreTable({ tableId: 'tbl-1', userId: 'user-1' }) + + expect(result.success).toBe(true) + expect(mockRestoreTable).toHaveBeenCalledWith('tbl-1', expect.any(String)) + }) +}) diff --git a/apps/sim/lib/table/orchestration/index.ts b/apps/sim/lib/table/orchestration/index.ts index af29da6aced..f5806be55da 100644 --- a/apps/sim/lib/table/orchestration/index.ts +++ b/apps/sim/lib/table/orchestration/index.ts @@ -1,5 +1,6 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' +import { ResourceLockedError } from '@sim/platform-authz/resource-lock' import { toError } from '@sim/utils/errors' import { generateRequestId } from '@/lib/core/utils/request' import { getTableById, restoreTable, TableConflictError } from '@/lib/table/service' @@ -7,7 +8,12 @@ import type { TableDefinition } from '@/lib/table/types' const logger = createLogger('TableOrchestration') -export type TableOrchestrationErrorCode = 'not_found' | 'validation' | 'conflict' | 'internal' +export type TableOrchestrationErrorCode = + | 'not_found' + | 'validation' + | 'conflict' + | 'locked' + | 'internal' export interface PerformRestoreTableParams { tableId: string @@ -59,6 +65,9 @@ export async function performRestoreTable( if (error instanceof TableConflictError) { return { success: false, error: error.message, errorCode: 'conflict' } } + if (error instanceof ResourceLockedError) { + return { success: false, error: error.message, errorCode: 'locked' } + } return { success: false, error: toError(error).message, errorCode: 'internal' } } } diff --git a/apps/sim/lib/table/service.ts b/apps/sim/lib/table/service.ts index 90f3c6f6e7a..7d844f1b05d 100644 --- a/apps/sim/lib/table/service.ts +++ b/apps/sim/lib/table/service.ts @@ -11,11 +11,17 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { tableJobs, userTableDefinitions, userTableRows } from '@sim/db/schema' import { createLogger } from '@sim/logger' +import { + assertFolderMutable, + assertResourceMutable, + assertResourceMutableUnlessUnlocking, +} from '@sim/platform-authz/resource-lock' import { getPostgresErrorCode } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { and, count, eq, isNull, sql } from 'drizzle-orm' import { generateRestoreName } from '@/lib/core/utils/restore-name' import type { DbOrTx } from '@/lib/db/types' +import { assertFolderParentValid } from '@/lib/folders/parent-validation' 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' @@ -41,6 +47,13 @@ export class TableConflictError extends Error { } } +export class TableInvalidFolderError extends Error { + readonly code = 'INVALID_FOLDER_ID' as const + constructor(reason: string) { + super(`Invalid folderId: ${reason}`) + } +} + export type TableScope = 'active' | 'archived' | 'all' /** @@ -128,6 +141,8 @@ export async function getTableById( metadata: userTableDefinitions.metadata, maxRows: userTableDefinitions.maxRows, workspaceId: userTableDefinitions.workspaceId, + folderId: userTableDefinitions.folderId, + locked: userTableDefinitions.locked, createdBy: userTableDefinitions.createdBy, archivedAt: userTableDefinitions.archivedAt, createdAt: userTableDefinitions.createdAt, @@ -156,6 +171,8 @@ export async function getTableById( rowCount: Math.max(0, table.rowCount - pendingDeleteRemaining), maxRows: table.maxRows, workspaceId: table.workspaceId, + folderId: table.folderId, + locked: table.locked, createdBy: table.createdBy, archivedAt: table.archivedAt, createdAt: table.createdAt, @@ -184,6 +201,8 @@ export async function listTables( metadata: userTableDefinitions.metadata, maxRows: userTableDefinitions.maxRows, workspaceId: userTableDefinitions.workspaceId, + folderId: userTableDefinitions.folderId, + locked: userTableDefinitions.locked, createdBy: userTableDefinitions.createdBy, archivedAt: userTableDefinitions.archivedAt, createdAt: userTableDefinitions.createdAt, @@ -220,6 +239,8 @@ export async function listTables( rowCount: Math.max(0, t.rowCount - pendingDeleteRemaining), maxRows: t.maxRows, workspaceId: t.workspaceId, + folderId: t.folderId, + locked: t.locked, createdBy: t.createdBy, archivedAt: t.archivedAt, createdAt: t.createdAt, @@ -270,6 +291,7 @@ export async function createTable( description: data.description ?? null, schema, workspaceId: data.workspaceId, + folderId: data.folderId ?? null, createdBy: data.userId, maxRows, archivedAt: null, @@ -285,14 +307,27 @@ export async function createTable( // Starter rows count against the plan too. Checked before the tx (the lookup is a // separate pool read) — a new table starts empty, so the footprint is just these. + // Folder-parent validity and row-capacity are disjoint checks with no dependency + // on each other, so they run concurrently rather than as two sequential awaits. const initialRowCount = data.initialRowCount ?? 0 - let rowLimit: number | undefined - if (initialRowCount > 0) { - rowLimit = await assertRowCapacity({ - workspaceId: data.workspaceId, - currentRowCount: 0, - addedRows: initialRowCount, - }) + const [parentError, rowLimit] = await Promise.all([ + data.folderId + ? assertFolderParentValid(data.folderId, { + workspaceId: data.workspaceId, + resourceType: 'table', + }) + : Promise.resolve(null), + initialRowCount > 0 + ? assertRowCapacity({ + workspaceId: data.workspaceId, + currentRowCount: 0, + addedRows: initialRowCount, + }) + : Promise.resolve(undefined), + ]) + + if (parentError) { + throw new TableInvalidFolderError(parentError.error) } // Wrap count check, duplicate check, and insert in a transaction with FOR UPDATE @@ -302,6 +337,13 @@ export async function createTable( await setTableTxTimeouts(trx) await trx.execute(sql`SELECT 1 FROM workspace WHERE id = ${data.workspaceId} FOR UPDATE`) + // assertFolderParentValid above only checks workspace/type/deleted-state -- without + // this, a table could be created directly inside a locked folder. Passing `trx` + // keeps this read inside the same transaction as the insert below. + if (data.folderId) { + await assertFolderMutable(data.folderId, 'table', trx) + } + const [{ count: existingCount }] = await trx .select({ count: count() }) .from(userTableDefinitions) @@ -391,6 +433,8 @@ export async function createTable( rowCount: data.initialRowCount ?? 0, maxRows: newTable.maxRows, workspaceId: newTable.workspaceId, + folderId: newTable.folderId, + locked: false, createdBy: newTable.createdBy, archivedAt: newTable.archivedAt, createdAt: newTable.createdAt, @@ -525,8 +569,18 @@ export async function renameTable( tableId: string, newName: string, requestId: string, - actingUserId?: string -): Promise<{ id: string; name: string }> { + actingUserId?: string, + /** When provided (including `null`), also moves the table to this folder. */ + folderId?: string | null, + locked?: boolean, + /** + * True when this call only toggles `locked` and neither the name nor the + * folder actually changes — the route computes this by diffing against the + * table's current row so an admin can still unlock a locked table (or + * lock/unlock without also being blocked by its own mutation). + */ + isLockOnlyUpdate = false +): Promise<{ id: string; name: string; folderId: string | null; locked: boolean }> { const nameValidation = validateTableName(newName) if (!nameValidation.valid) { throw new Error(nameValidation.errors.join(', ')) @@ -534,21 +588,69 @@ export async function renameTable( const now = new Date() try { - const result = await db - .update(userTableDefinitions) - .set({ name: newName, updatedAt: now }) - .where(eq(userTableDefinitions.id, tableId)) - .returning({ - id: userTableDefinitions.id, - createdBy: userTableDefinitions.createdBy, - workspaceId: userTableDefinitions.workspaceId, - }) + const result = await db.transaction(async (trx) => { + // The lock/folder-validity checks and the write below all run inside this + // transaction (joining `trx`) so a lock toggled on the table or its target + // folder in the window between an outer pre-check and this write can't be + // silently bypassed -- matching the same recheck pattern used by + // deleteTable/createTable/updateKnowledgeBase in this file. + // + // `isLockOnlyUpdate` is false whenever name/folderId also change, but an admin + // combining `locked: false` with those changes in one request is unlocking the + // table as part of this same atomic write -- the mutable-check must not treat + // that request's own current (about-to-be-cleared) lock as blocking. It must + // still enforce a lock inherited from the table's containing folder, since + // clearing the table's own `locked` flag doesn't affect that. + if (!isLockOnlyUpdate) { + await assertResourceMutableUnlessUnlocking('table', tableId, locked === false, trx) + } - if (result.length === 0) { - throw new Error(`Table ${tableId} not found`) - } + if (folderId) { + const [tableRow] = await trx + .select({ workspaceId: userTableDefinitions.workspaceId }) + .from(userTableDefinitions) + .where(eq(userTableDefinitions.id, tableId)) + .limit(1) + if (!tableRow) { + throw new Error(`Table ${tableId} not found`) + } + const parentError = await assertFolderParentValid( + folderId, + { workspaceId: tableRow.workspaceId, resourceType: 'table' }, + trx + ) + if (parentError) { + throw new TableInvalidFolderError(parentError.error) + } + // assertResourceMutable above only checked the table's *current* folder chain — + // without this, a table could be moved out of an unlocked folder into a locked one. + await assertFolderMutable(folderId, 'table', trx) + } + + const rows = await trx + .update(userTableDefinitions) + .set({ + name: newName, + updatedAt: now, + ...(folderId !== undefined ? { folderId } : {}), + ...(locked !== undefined ? { locked } : {}), + }) + .where(eq(userTableDefinitions.id, tableId)) + .returning({ + id: userTableDefinitions.id, + createdBy: userTableDefinitions.createdBy, + workspaceId: userTableDefinitions.workspaceId, + folderId: userTableDefinitions.folderId, + locked: userTableDefinitions.locked, + }) + + if (rows.length === 0) { + throw new Error(`Table ${tableId} not found`) + } + return rows[0] + }) - const { createdBy, workspaceId } = result[0] + const { createdBy, workspaceId, folderId: updatedFolderId, locked: updatedLocked } = result const renameActorId = actingUserId ?? createdBy if (renameActorId) { recordAudit({ @@ -564,7 +666,7 @@ export async function renameTable( } logger.info(`[${requestId}] Renamed table ${tableId} to "${newName}"`) - return { id: tableId, name: newName } + return { id: tableId, name: newName, folderId: updatedFolderId, locked: updatedLocked } } catch (error: unknown) { if (getPostgresErrorCode(error) === '23505') { throw new TableConflictError(newName) @@ -649,16 +751,26 @@ export async function deleteTable( requestId: string, actingUserId?: string ): Promise { + await assertResourceMutable('table', tableId) + const now = new Date() - const result = await db - .update(userTableDefinitions) - .set({ archivedAt: now, updatedAt: now }) - .where(and(eq(userTableDefinitions.id, tableId), isNull(userTableDefinitions.archivedAt))) - .returning({ - createdBy: userTableDefinitions.createdBy, - workspaceId: userTableDefinitions.workspaceId, - name: userTableDefinitions.name, - }) + const result = await db.transaction(async (tx) => { + // The pre-check above is a separate round-trip -- an admin could lock this + // table in the window between that check and this transaction. Re-check + // inside the transaction (joining `tx` so the read is part of the same + // atomic unit as the write below) before applying anything. + await assertResourceMutable('table', tableId, tx) + + return tx + .update(userTableDefinitions) + .set({ archivedAt: now, updatedAt: now }) + .where(and(eq(userTableDefinitions.id, tableId), isNull(userTableDefinitions.archivedAt))) + .returning({ + createdBy: userTableDefinitions.createdBy, + workspaceId: userTableDefinitions.workspaceId, + name: userTableDefinitions.name, + }) + }) const deleted = result[0] // Audit only genuine user deletes — rollback callers omit `actingUserId`. The @@ -713,6 +825,12 @@ export async function restoreTable(tableId: string, requestId: string): Promise< await setTableTxTimeouts(tx) await tx.execute(sql`SELECT 1 FROM user_table_definitions WHERE id = ${tableId} FOR UPDATE`) + // Restore doesn't change folderId, so this correctly evaluates both the table's own + // `locked` flag and its (unchanged) containing folder chain. Runs inside the same + // transaction as the FOR UPDATE lock above and the write below, closing the TOCTOU + // window where a concurrent request locks the table or its folder in between. + await assertResourceMutable('table', tableId, tx) + attemptedRestoreName = await generateRestoreName(table.name, async (candidate) => { const [match] = await tx .select({ id: userTableDefinitions.id }) diff --git a/apps/sim/lib/table/types.ts b/apps/sim/lib/table/types.ts index 26dcb886bba..b048388d190 100644 --- a/apps/sim/lib/table/types.ts +++ b/apps/sim/lib/table/types.ts @@ -341,6 +341,8 @@ export interface TableDefinition { rowCount: number maxRows: number workspaceId: string + folderId?: string | null + locked: boolean createdBy: string archivedAt?: Date | string | null createdAt: Date | string @@ -493,6 +495,8 @@ export interface CreateTableData { schema: TableSchema workspaceId: string userId: string + /** Folder to create the table in. Omit or `null` for the workspace root. */ + folderId?: string | null /** Optional stored row cap. Vestigial under plan-based enforcement (the column is no longer * consulted on insert), but retained so callers that still set it type-check. */ maxRows?: number diff --git a/apps/sim/lib/uploads/contexts/workspace/workspace-file-folder-manager.ts b/apps/sim/lib/uploads/contexts/workspace/workspace-file-folder-manager.ts index 5a8e47f72b9..79a599ce0f9 100644 --- a/apps/sim/lib/uploads/contexts/workspace/workspace-file-folder-manager.ts +++ b/apps/sim/lib/uploads/contexts/workspace/workspace-file-folder-manager.ts @@ -1,10 +1,17 @@ import { db } from '@sim/db' -import { workspaceFileFolder, workspaceFiles } from '@sim/db/schema' +import { folder as workspaceFileFolder, workspaceFiles } from '@sim/db/schema' import { createLogger } from '@sim/logger' +import { + assertFolderMutable, + assertFolderMutableUnlessUnlocking, + assertResourceMutable, + ResourceLockedError, +} from '@sim/platform-authz/resource-lock' import { getPostgresErrorCode } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { and, asc, eq, inArray, isNull, min, type SQL, sql } from 'drizzle-orm' import { isReservedWorkflowAliasBackingDisplayPath } from '@/lib/copilot/vfs/workflow-aliases' +import { collectDescendantFolderIds } from '@/lib/folders/subtree' import { getWorkspaceWithOwner } from '@/lib/workspaces/permissions/utils' const logger = createLogger('WorkspaceFileFolders') @@ -54,6 +61,7 @@ export interface WorkspaceFileFolderRecord { parentId: string | null path: string sortOrder: number + locked: boolean deletedAt: Date | null createdAt: Date updatedAt: Date @@ -66,6 +74,7 @@ interface RawWorkspaceFileFolder { name: string parentId: string | null sortOrder: number + locked: boolean deletedAt: Date | null createdAt: Date updatedAt: Date @@ -164,6 +173,7 @@ function mapFolder( parentId: folder.parentId, path: paths.get(folder.id) ?? folder.name, sortOrder: folder.sortOrder, + locked: folder.locked, deletedAt: folder.deletedAt, createdAt: folder.createdAt, updatedAt: folder.updatedAt, @@ -183,11 +193,13 @@ async function getRawWorkspaceFileFolder( includeDeleted ? and( eq(workspaceFileFolder.id, folderId), - eq(workspaceFileFolder.workspaceId, workspaceId) + eq(workspaceFileFolder.workspaceId, workspaceId), + eq(workspaceFileFolder.resourceType, 'file') ) : and( eq(workspaceFileFolder.id, folderId), eq(workspaceFileFolder.workspaceId, workspaceId), + eq(workspaceFileFolder.resourceType, 'file'), isNull(workspaceFileFolder.deletedAt) ) ) @@ -207,6 +219,7 @@ async function findRawWorkspaceFileFolderByName( .where( and( eq(workspaceFileFolder.workspaceId, workspaceId), + eq(workspaceFileFolder.resourceType, 'file'), eq(workspaceFileFolder.name, name), folderParentCondition(parentId), isNull(workspaceFileFolder.deletedAt) @@ -295,14 +308,19 @@ export async function listWorkspaceFileFolders( .from(workspaceFileFolder) .where( scope === 'all' - ? eq(workspaceFileFolder.workspaceId, workspaceId) + ? and( + eq(workspaceFileFolder.workspaceId, workspaceId), + eq(workspaceFileFolder.resourceType, 'file') + ) : scope === 'archived' ? and( eq(workspaceFileFolder.workspaceId, workspaceId), + eq(workspaceFileFolder.resourceType, 'file'), sql`${workspaceFileFolder.deletedAt} IS NOT NULL` ) : and( eq(workspaceFileFolder.workspaceId, workspaceId), + eq(workspaceFileFolder.resourceType, 'file'), isNull(workspaceFileFolder.deletedAt) ) ) @@ -333,9 +351,13 @@ export async function getWorkspaceFileFolder( .from(workspaceFileFolder) .where( includeDeleted - ? eq(workspaceFileFolder.workspaceId, workspaceId) + ? and( + eq(workspaceFileFolder.workspaceId, workspaceId), + eq(workspaceFileFolder.resourceType, 'file') + ) : and( eq(workspaceFileFolder.workspaceId, workspaceId), + eq(workspaceFileFolder.resourceType, 'file'), isNull(workspaceFileFolder.deletedAt) ) ) @@ -355,6 +377,9 @@ export async function assertWorkspaceFileFolderTarget( if (!folder) { throw new Error('Target folder not found') } + // Both callers (upload, register-after-upload) create a new file inside this + // folder -- without this, a file could be created directly in a locked folder. + await assertFolderMutable(normalized, 'file') return normalized } @@ -365,6 +390,7 @@ export async function createWorkspaceFileFolder(params: { name: string parentId?: string | null sortOrder?: number + id?: string }): Promise { const name = normalizeWorkspaceFileItemName(params.name, 'Folder') @@ -380,6 +406,7 @@ export async function createWorkspaceFileFolder(params: { and( eq(workspaceFileFolder.id, parentId), eq(workspaceFileFolder.workspaceId, params.workspaceId), + eq(workspaceFileFolder.resourceType, 'file'), isNull(workspaceFileFolder.deletedAt) ) ) @@ -388,6 +415,10 @@ export async function createWorkspaceFileFolder(params: { if (!target) { throw new Error('Target folder not found') } + + // A folder in an unlocked location could otherwise gain a new child inside + // a locked one. + await assertFolderMutable(parentId, 'file', tx) } const existingFolders = await tx @@ -396,6 +427,7 @@ export async function createWorkspaceFileFolder(params: { .where( and( eq(workspaceFileFolder.workspaceId, params.workspaceId), + eq(workspaceFileFolder.resourceType, 'file'), eq(workspaceFileFolder.name, name), folderParentCondition(parentId), isNull(workspaceFileFolder.deletedAt) @@ -413,17 +445,19 @@ export async function createWorkspaceFileFolder(params: { .where( and( eq(workspaceFileFolder.workspaceId, params.workspaceId), + eq(workspaceFileFolder.resourceType, 'file'), folderParentCondition(parentId), isNull(workspaceFileFolder.deletedAt) ) ) - const id = generateId() + const id = params.id || generateId() try { const [inserted] = await tx .insert(workspaceFileFolder) .values({ id, + resourceType: 'file', name, userId: params.userId, workspaceId: params.workspaceId, @@ -460,6 +494,7 @@ export async function ensureWorkspaceFileFolderPath(params: { .where( and( eq(workspaceFileFolder.workspaceId, params.workspaceId), + eq(workspaceFileFolder.resourceType, 'file'), isNull(workspaceFileFolder.deletedAt) ) ) @@ -498,6 +533,7 @@ export async function ensureWorkspaceFileFolderPath(params: { name: created.name, parentId: created.parentId, sortOrder: created.sortOrder, + locked: created.locked, deletedAt: created.deletedAt, createdAt: created.createdAt, updatedAt: created.updatedAt, @@ -531,40 +567,13 @@ export async function ensureWorkspaceFileFolderPath(params: { return parentId } -function collectDescendantFolderIds( - folders: Array>, - folderId: string -): string[] { - const childrenByParent = new Map() - - for (const folder of folders) { - if (!folder.parentId) continue - const children = childrenByParent.get(folder.parentId) ?? [] - children.push(folder.id) - childrenByParent.set(folder.parentId, children) - } - - const descendants: string[] = [] - const seen = new Set([folderId]) - const visit = (id: string) => { - for (const childId of childrenByParent.get(id) ?? []) { - if (seen.has(childId)) continue - seen.add(childId) - descendants.push(childId) - visit(childId) - } - } - visit(folderId) - - return descendants -} - export async function updateWorkspaceFileFolder(params: { workspaceId: string folderId: string name?: string parentId?: string | null sortOrder?: number + locked?: boolean }): Promise { const folder = await db.transaction(async (tx) => { await acquireWorkspaceFileFolderMutationLock(tx, params.workspaceId) @@ -576,6 +585,7 @@ export async function updateWorkspaceFileFolder(params: { and( eq(workspaceFileFolder.id, params.folderId), eq(workspaceFileFolder.workspaceId, params.workspaceId), + eq(workspaceFileFolder.resourceType, 'file'), isNull(workspaceFileFolder.deletedAt) ) ) @@ -583,6 +593,21 @@ export async function updateWorkspaceFileFolder(params: { if (!existing) throw new Error('Folder not found') + // The route checks lock state before calling this function, but that's a + // separate round-trip -- an admin could lock this folder in the window + // between that check and this transaction. Re-check inside the transaction + // (joining `tx` so the read is part of the same atomic unit as the write + // below) before applying anything. Unlike a full skip for a lock-only + // update, `assertFolderMutableUnlessUnlocking` only treats this folder's own + // (about-to-be-cleared) lock as satisfied -- a lock inherited from an + // ancestor still blocks, since clearing this folder's own flag doesn't + // affect that. + const isLockOnlyUpdate = + params.name === undefined && params.parentId === undefined && params.sortOrder === undefined + if (!isLockOnlyUpdate) { + await assertFolderMutableUnlessUnlocking(params.folderId, 'file', params.locked === false, tx) + } + const updates: Partial = { updatedAt: new Date() } const finalName = params.name !== undefined @@ -601,6 +626,7 @@ export async function updateWorkspaceFileFolder(params: { and( eq(workspaceFileFolder.id, finalParentId), eq(workspaceFileFolder.workspaceId, params.workspaceId), + eq(workspaceFileFolder.resourceType, 'file'), isNull(workspaceFileFolder.deletedAt) ) ) @@ -609,6 +635,13 @@ export async function updateWorkspaceFileFolder(params: { if (!target) { throw new Error('Target folder not found') } + + // A folder in an unlocked location could otherwise be moved into a locked + // one -- this check is independent of the isLockOnlyUpdate skip above, + // since moving into a locked folder is never a lock-only operation. + if (finalParentId !== existing.parentId) { + await assertFolderMutable(finalParentId, 'file', tx) + } } if (params.parentId !== undefined) { @@ -618,6 +651,7 @@ export async function updateWorkspaceFileFolder(params: { .where( and( eq(workspaceFileFolder.workspaceId, params.workspaceId), + eq(workspaceFileFolder.resourceType, 'file'), isNull(workspaceFileFolder.deletedAt) ) ) @@ -635,6 +669,7 @@ export async function updateWorkspaceFileFolder(params: { .where( and( eq(workspaceFileFolder.workspaceId, params.workspaceId), + eq(workspaceFileFolder.resourceType, 'file'), eq(workspaceFileFolder.name, finalName), folderParentCondition(finalParentId), isNull(workspaceFileFolder.deletedAt) @@ -659,6 +694,10 @@ export async function updateWorkspaceFileFolder(params: { updates.sortOrder = params.sortOrder } + if (params.locked !== undefined) { + updates.locked = params.locked + } + try { const [updatedFolder] = await tx .update(workspaceFileFolder) @@ -667,6 +706,7 @@ export async function updateWorkspaceFileFolder(params: { and( eq(workspaceFileFolder.id, params.folderId), eq(workspaceFileFolder.workspaceId, params.workspaceId), + eq(workspaceFileFolder.resourceType, 'file'), isNull(workspaceFileFolder.deletedAt) ) ) @@ -729,6 +769,7 @@ export async function moveWorkspaceFileItems(params: { and( eq(workspaceFileFolder.id, targetFolderId), eq(workspaceFileFolder.workspaceId, params.workspaceId), + eq(workspaceFileFolder.resourceType, 'file'), isNull(workspaceFileFolder.deletedAt) ) ) @@ -750,6 +791,7 @@ export async function moveWorkspaceFileItems(params: { .where( and( eq(workspaceFileFolder.workspaceId, params.workspaceId), + eq(workspaceFileFolder.resourceType, 'file'), isNull(workspaceFileFolder.deletedAt) ) ) @@ -786,6 +828,7 @@ export async function moveWorkspaceFileItems(params: { and( inArray(workspaceFileFolder.id, folderIds), eq(workspaceFileFolder.workspaceId, params.workspaceId), + eq(workspaceFileFolder.resourceType, 'file'), isNull(workspaceFileFolder.deletedAt) ) ) @@ -801,6 +844,20 @@ export async function moveWorkspaceFileItems(params: { throw new WorkspaceFileItemsNotFoundError(missingFileIds, missingFolderIds) } + // The caller's own lock checks (before this transaction opened) only cover each + // moved item's *and* the target folder's state at that earlier point in time -- + // re-checking here, inside the same transaction as the writes below, closes the + // window where a concurrent request locks an item or the destination in between. + for (const fileId of movingFileIds) { + await assertResourceMutable('file', fileId, tx) + } + for (const folderId of movingFolderIds) { + await assertFolderMutable(folderId, 'file', tx) + } + if (targetFolderId) { + await assertFolderMutable(targetFolderId, 'file', tx) + } + for (const file of movingFiles) { const conflictingFiles = await tx .select({ id: workspaceFiles.id }) @@ -830,6 +887,7 @@ export async function moveWorkspaceFileItems(params: { .where( and( eq(workspaceFileFolder.workspaceId, params.workspaceId), + eq(workspaceFileFolder.resourceType, 'file'), eq(workspaceFileFolder.name, folder.name), folderParentCondition(targetFolderId), isNull(workspaceFileFolder.deletedAt) @@ -873,6 +931,7 @@ export async function moveWorkspaceFileItems(params: { and( inArray(workspaceFileFolder.id, folderIds), eq(workspaceFileFolder.workspaceId, params.workspaceId), + eq(workspaceFileFolder.resourceType, 'file'), isNull(workspaceFileFolder.deletedAt) ) ) @@ -899,6 +958,7 @@ export async function archiveWorkspaceFileFolderRecursive( and( eq(workspaceFileFolder.id, folderId), eq(workspaceFileFolder.workspaceId, workspaceId), + eq(workspaceFileFolder.resourceType, 'file'), isNull(workspaceFileFolder.deletedAt) ) ) @@ -906,11 +966,22 @@ export async function archiveWorkspaceFileFolderRecursive( if (!folder) throw new Error('Folder not found') + // The route checks lock state before calling this function, but that's a + // separate round-trip -- an admin could lock this folder in the window + // between that check and this transaction. Re-check inside the transaction + // (joining `tx` so the read is part of the same atomic unit as the writes + // below) before applying anything. + await assertFolderMutable(folderId, 'file', tx) + const activeFolders = await tx .select({ id: workspaceFileFolder.id, parentId: workspaceFileFolder.parentId }) .from(workspaceFileFolder) .where( - and(eq(workspaceFileFolder.workspaceId, workspaceId), isNull(workspaceFileFolder.deletedAt)) + and( + eq(workspaceFileFolder.workspaceId, workspaceId), + eq(workspaceFileFolder.resourceType, 'file'), + isNull(workspaceFileFolder.deletedAt) + ) ) const folderIds = [folderId, ...collectDescendantFolderIds(activeFolders, folderId)] @@ -934,6 +1005,7 @@ export async function archiveWorkspaceFileFolderRecursive( and( inArray(workspaceFileFolder.id, folderIds), eq(workspaceFileFolder.workspaceId, workspaceId), + eq(workspaceFileFolder.resourceType, 'file'), isNull(workspaceFileFolder.deletedAt) ) ) @@ -966,7 +1038,11 @@ export async function restoreWorkspaceFileFolder( .select() .from(workspaceFileFolder) .where( - and(eq(workspaceFileFolder.id, folderId), eq(workspaceFileFolder.workspaceId, workspaceId)) + and( + eq(workspaceFileFolder.id, folderId), + eq(workspaceFileFolder.workspaceId, workspaceId), + eq(workspaceFileFolder.resourceType, 'file') + ) ) .limit(1) .then((rows) => rows[0] ?? null) @@ -974,6 +1050,12 @@ export async function restoreWorkspaceFileFolder( if (!raw) throw new Error('Folder not found') if (!raw.deletedAt) throw new Error('Folder is not archived') + // The folder row is soft-deleted, so the generic lock engine (which only reads + // active rows) can't see it -- check its own `locked` flag directly. + if (raw.locked) { + throw new ResourceLockedError('file', false, 'Folder is locked') + } + const folderDeletedAt = raw.deletedAt // If the parent folder is still archived, restore to root so the folder @@ -986,7 +1068,8 @@ export async function restoreWorkspaceFileFolder( .where( and( eq(workspaceFileFolder.id, resolvedParentId), - eq(workspaceFileFolder.workspaceId, workspaceId) + eq(workspaceFileFolder.workspaceId, workspaceId), + eq(workspaceFileFolder.resourceType, 'file') ) ) .limit(1) @@ -994,6 +1077,13 @@ export async function restoreWorkspaceFileFolder( if (!parent || parent.deletedAt) resolvedParentId = null } + // resolvedParentId is either null (root, always safe) or a confirmed-active + // folder -- without this, the folder could resurface under a folder that was + // locked after this one was deleted. Passing `tx` (not the default db client) + // keeps this read inside the same transaction as the write below, closing the + // TOCTOU window where a concurrent request locks resolvedParentId in between. + await assertFolderMutable(resolvedParentId, 'file', tx) + const stats: WorkspaceFileArchiveResult = { folders: 0, files: 0 } const seen = new Set() const restoreFolderSubtree = async (currentFolderId: string): Promise => { @@ -1021,6 +1111,7 @@ export async function restoreWorkspaceFileFolder( and( eq(workspaceFileFolder.parentId, currentFolderId), eq(workspaceFileFolder.workspaceId, workspaceId), + eq(workspaceFileFolder.resourceType, 'file'), eq(workspaceFileFolder.deletedAt, folderDeletedAt) ) ) @@ -1033,6 +1124,7 @@ export async function restoreWorkspaceFileFolder( and( eq(workspaceFileFolder.id, child.id), eq(workspaceFileFolder.workspaceId, workspaceId), + eq(workspaceFileFolder.resourceType, 'file'), eq(workspaceFileFolder.deletedAt, folderDeletedAt) ) ) @@ -1048,7 +1140,11 @@ export async function restoreWorkspaceFileFolder( .update(workspaceFileFolder) .set({ deletedAt: null, parentId: resolvedParentId, updatedAt: new Date() }) .where( - and(eq(workspaceFileFolder.id, folderId), eq(workspaceFileFolder.workspaceId, workspaceId)) + and( + eq(workspaceFileFolder.id, folderId), + eq(workspaceFileFolder.workspaceId, workspaceId), + eq(workspaceFileFolder.resourceType, 'file') + ) ) .returning() @@ -1064,7 +1160,11 @@ export async function restoreWorkspaceFileFolder( .select() .from(workspaceFileFolder) .where( - and(eq(workspaceFileFolder.workspaceId, workspaceId), isNull(workspaceFileFolder.deletedAt)) + and( + eq(workspaceFileFolder.workspaceId, workspaceId), + eq(workspaceFileFolder.resourceType, 'file'), + isNull(workspaceFileFolder.deletedAt) + ) ) const paths = buildWorkspaceFileFolderPathMap(allFolders) return { @@ -1085,6 +1185,17 @@ export async function bulkArchiveWorkspaceFileItems(params: { return db.transaction(async (tx) => { await acquireWorkspaceFileFolderMutationLock(tx, params.workspaceId) + // The route checks lock state before calling this function, but that's a + // separate round-trip -- an admin could lock a file or folder in the window + // between that check and this transaction. Re-check inside the transaction + // (joining `tx`) before applying anything. Safe to check each id in turn + // without a closure-based ordered lock (unlike the reorder batch fix): the + // advisory lock above already fully serializes every file-folder mutation + // for this workspace, so no other transaction can be concurrently + // acquiring row locks here to race against. + await Promise.all(explicitFileIds.map((id) => assertResourceMutable('file', id, tx))) + await Promise.all(explicitFolderIds.map((id) => assertFolderMutable(id, 'file', tx))) + const activeFolders = explicitFolderIds.length > 0 ? await tx @@ -1093,6 +1204,7 @@ export async function bulkArchiveWorkspaceFileItems(params: { .where( and( eq(workspaceFileFolder.workspaceId, params.workspaceId), + eq(workspaceFileFolder.resourceType, 'file'), isNull(workspaceFileFolder.deletedAt) ) ) @@ -1143,6 +1255,7 @@ export async function bulkArchiveWorkspaceFileItems(params: { and( inArray(workspaceFileFolder.id, allFolderIds), eq(workspaceFileFolder.workspaceId, params.workspaceId), + eq(workspaceFileFolder.resourceType, 'file'), isNull(workspaceFileFolder.deletedAt) ) ) diff --git a/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts b/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts index 8e091d8d8f2..6aaec986c3f 100644 --- a/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts +++ b/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts @@ -7,6 +7,10 @@ import { randomBytes } from 'crypto' import { db } from '@sim/db' import { workspaceFiles } from '@sim/db/schema' import { createLogger } from '@sim/logger' +import { + assertResourceMutableUnlessUnlocking, + ResourceLockedError, +} from '@sim/platform-authz/resource-lock' import { getErrorMessage, getPostgresConstraintName, getPostgresErrorCode } from '@sim/utils/errors' import { generateShortId } from '@sim/utils/id' import { and, eq, isNull, sql } from 'drizzle-orm' @@ -68,6 +72,7 @@ export interface WorkspaceFileRecord { uploadedBy: string folderId?: string | null folderPath?: string | null + locked: boolean deletedAt?: Date | null uploadedAt: Date updatedAt: Date @@ -580,6 +585,7 @@ function mapWorkspaceFileRecord( uploadedBy: file.userId, folderId: file.folderId, folderPath: file.folderId ? (folderPaths.get(file.folderId) ?? null) : null, + locked: file.locked, deletedAt: file.deletedAt, uploadedAt: file.uploadedAt, updatedAt: file.updatedAt, @@ -963,7 +969,8 @@ export async function updateWorkspaceFileContent( export async function renameWorkspaceFile( workspaceId: string, fileId: string, - newName: string + newName: string, + locked?: boolean ): Promise { logger.info(`Renaming workspace file: ${fileId} to "${newName}" in workspace ${workspaceId}`) @@ -975,28 +982,52 @@ export async function renameWorkspaceFile( throw new Error('File not found') } - if (fileRecord.name === normalizedName) { + if (fileRecord.name === normalizedName && locked === undefined) { return fileRecord } - const exists = await fileExistsInWorkspace(workspaceId, normalizedName, fileRecord.folderId) - if (exists) { - throw new FileConflictError(normalizedName) + if (fileRecord.name !== normalizedName) { + const exists = await fileExistsInWorkspace(workspaceId, normalizedName, fileRecord.folderId) + if (exists) { + throw new FileConflictError(normalizedName) + } } + const isLockOnlyUpdate = fileRecord.name === normalizedName + let updated: { id: string }[] try { - updated = await db - .update(workspaceFiles) - .set({ originalName: normalizedName, updatedAt: new Date() }) - .where( - and( - eq(workspaceFiles.id, fileId), - eq(workspaceFiles.workspaceId, workspaceId), - eq(workspaceFiles.context, 'workspace') + updated = await db.transaction(async (tx) => { + // The caller checks lock state before calling this function, but that's a + // separate round-trip -- an admin could lock this file in the window + // between that check and this transaction. Re-check inside the + // transaction (joining `tx`) before applying anything. + // + // `assertResourceMutableUnlessUnlocking` only treats this file's own + // (about-to-be-cleared) lock as satisfied when this same request unlocks + // it -- a lock inherited from an ancestor folder still blocks. Skipped + // entirely for a lock-only update, matching the file/kb/table + // lock-toggle precedent. + if (!isLockOnlyUpdate) { + await assertResourceMutableUnlessUnlocking('file', fileId, locked === false, tx) + } + + return tx + .update(workspaceFiles) + .set({ + originalName: normalizedName, + updatedAt: new Date(), + ...(locked !== undefined ? { locked } : {}), + }) + .where( + and( + eq(workspaceFiles.id, fileId), + eq(workspaceFiles.workspaceId, workspaceId), + eq(workspaceFiles.context, 'workspace') + ) ) - ) - .returning({ id: workspaceFiles.id }) + .returning({ id: workspaceFiles.id }) + }) } catch (error: unknown) { if (getPostgresErrorCode(error) === '23505') { throw new FileConflictError(normalizedName) @@ -1013,6 +1044,7 @@ export async function renameWorkspaceFile( return { ...fileRecord, name: normalizedName, + locked: locked !== undefined ? locked : fileRecord.locked, } } @@ -1067,6 +1099,14 @@ export async function restoreWorkspaceFile(workspaceId: string, fileId: string): throw new Error('Cannot restore file into an archived workspace') } + // The soft-deleted file's own `locked` flag still gates restoring it -- the + // generic lock engine only reads active rows, so this direct check (rather + // than assertResourceMutable, which would find nothing) matches the pattern + // used by the other resource restore paths. + if (fileRecord.locked) { + throw new ResourceLockedError('file', false, 'File is locked') + } + /** * A concurrent upload/rename can claim the chosen name after `generateRestoreName`'s check (MVCC). * Retries pick a new random suffix; 23505 maps to {@link FileConflictError} after exhaustion. diff --git a/apps/sim/lib/workflows/lifecycle.ts b/apps/sim/lib/workflows/lifecycle.ts index 82040e2750e..2bb66ec466a 100644 --- a/apps/sim/lib/workflows/lifecycle.ts +++ b/apps/sim/lib/workflows/lifecycle.ts @@ -5,7 +5,7 @@ import { webhook, workflow, workflowDeploymentVersion, - workflowFolder, + folder as workflowFolder, workflowMcpTool, workflowSchedule, workspace, @@ -235,7 +235,7 @@ export async function restoreWorkflow( let clearFolderId = false if (existingWorkflow.folderId) { const [folder] = await db - .select({ archivedAt: workflowFolder.archivedAt }) + .select({ archivedAt: workflowFolder.deletedAt }) .from(workflowFolder) .where(eq(workflowFolder.id, existingWorkflow.folderId)) diff --git a/apps/sim/lib/workflows/orchestration/folder-lifecycle.ts b/apps/sim/lib/workflows/orchestration/folder-lifecycle.ts index c32ffbc58c3..4b9bad1d98c 100644 --- a/apps/sim/lib/workflows/orchestration/folder-lifecycle.ts +++ b/apps/sim/lib/workflows/orchestration/folder-lifecycle.ts @@ -1,29 +1,38 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' -import { - chat, - webhook, - workflow, - workflowFolder, - workflowMcpTool, - workflowSchedule, -} from '@sim/db/schema' +import { chat, folder, webhook, workflow, workflowMcpTool, workflowSchedule } from '@sim/db/schema' import { createLogger } from '@sim/logger' +import { + assertFolderMutable, + assertFolderMutableUnlessUnlocking, + ResourceLockedError, +} from '@sim/platform-authz/resource-lock' +import { getPostgresErrorCode } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { and, eq, inArray, isNull, min } from 'drizzle-orm' +import { + assertFolderParentValid, + checkFolderCircularReference, +} from '@/lib/folders/parent-validation' import { archiveWorkflowsByIdsInWorkspace } from '@/lib/workflows/lifecycle' import type { OrchestrationErrorCode } from '@/lib/workflows/orchestration/types' -import { checkForCircularReference } from '@/lib/workflows/utils' const logger = createLogger('FolderLifecycle') +/** All queries against `folder` in this module are scoped to workflow folders. */ +const isWorkflowFolder = eq(folder.resourceType, 'workflow') + +/** Marks a concurrent-deletion race on a target parent, caught inside a folder-create or folder-update transaction, as a 400 (validation), not a 500. */ +class WorkflowFolderParentNotFoundError extends Error {} +/** Marks a cycle caught inside the folder-update transaction, as a 400 (validation), not a 500. */ +class WorkflowFolderCycleError extends Error {} + export interface PerformCreateFolderParams { userId: string workspaceId: string name: string id?: string parentId?: string | null - color?: string sortOrder?: number } @@ -31,7 +40,7 @@ export interface PerformCreateFolderResult { success: boolean error?: string errorCode?: OrchestrationErrorCode - folder?: typeof workflowFolder.$inferSelect + folder?: typeof folder.$inferSelect } export interface PerformUpdateFolderParams { @@ -39,8 +48,6 @@ export interface PerformUpdateFolderParams { workspaceId: string userId: string name?: string - color?: string - isExpanded?: boolean locked?: boolean parentId?: string | null sortOrder?: number @@ -50,52 +57,23 @@ export interface PerformUpdateFolderResult { success: boolean error?: string errorCode?: OrchestrationErrorCode - folder?: typeof workflowFolder.$inferSelect -} - -/** - * Verifies that a prospective parent folder exists, belongs to the target - * workspace, and is not archived. Mirrors the validation in the duplicate - * route's `assertTargetParentFolderMutable` so a caller cannot reparent a - * folder to a non-existent id or to a folder in another workspace. Returns - * an error result when invalid, or `null` when the parent is acceptable. - */ -async function assertParentFolderInWorkspace( - parentId: string, - workspaceId: string -): Promise<{ error: string; errorCode: OrchestrationErrorCode } | null> { - const [parent] = await db - .select({ - workspaceId: workflowFolder.workspaceId, - archivedAt: workflowFolder.archivedAt, - }) - .from(workflowFolder) - .where(eq(workflowFolder.id, parentId)) - .limit(1) - - if (!parent || parent.workspaceId !== workspaceId || parent.archivedAt) { - return { error: 'Parent folder not found', errorCode: 'validation' } - } - - return null + folder?: typeof folder.$inferSelect } async function nextFolderSortOrder( workspaceId: string, parentId: string | null | undefined ): Promise { - const folderParentCondition = parentId - ? eq(workflowFolder.parentId, parentId) - : isNull(workflowFolder.parentId) + const folderParentCondition = parentId ? eq(folder.parentId, parentId) : isNull(folder.parentId) const workflowParentCondition = parentId ? eq(workflow.folderId, parentId) : isNull(workflow.folderId) const [[folderResult], [workflowResult]] = await Promise.all([ db - .select({ minSortOrder: min(workflowFolder.sortOrder) }) - .from(workflowFolder) - .where(and(eq(workflowFolder.workspaceId, workspaceId), folderParentCondition)), + .select({ minSortOrder: min(folder.sortOrder) }) + .from(folder) + .where(and(eq(folder.workspaceId, workspaceId), isWorkflowFolder, folderParentCondition)), db .select({ minSortOrder: min(workflow.sortOrder) }) .from(workflow) @@ -128,7 +106,10 @@ export async function performCreateFolder( errorCode: 'validation', } } - const parentError = await assertParentFolderInWorkspace(parentId, params.workspaceId) + const parentError = await assertFolderParentValid(parentId, { + workspaceId: params.workspaceId, + resourceType: 'workflow', + }) if (parentError) return { success: false, ...parentError } } @@ -137,18 +118,43 @@ export async function performCreateFolder( ? params.sortOrder : await nextFolderSortOrder(params.workspaceId, parentId) - const [folder] = await db - .insert(workflowFolder) - .values({ - id: folderId, - name: params.name.trim(), - userId: params.userId, - workspaceId: params.workspaceId, - parentId, - color: params.color || '#6B7280', - sortOrder, - }) - .returning() + const createdFolder = await db.transaction(async (tx) => { + if (parentId) { + // assertFolderParentValid above only reads at validation time, using the + // default (non-tx) db client -- a parent locked or soft-deleted after that + // read but before this transaction must still be caught. Re-check inside + // the transaction (joining `tx`) before inserting. + await assertFolderMutable(parentId, 'workflow', tx) + + // assertFolderMutable's lock walker treats a deleted parent as unlocked + // (it stops the ancestor walk when the row is missing rather than + // erroring), so it alone doesn't catch a parent deleted in that same + // window. FOR UPDATE here makes the recheck race-free. + const [targetParent] = await tx + .select({ id: folder.id }) + .from(folder) + .where(and(eq(folder.id, parentId), isWorkflowFolder, isNull(folder.deletedAt))) + .for('update') + .limit(1) + if (!targetParent) { + throw new WorkflowFolderParentNotFoundError('Parent folder not found') + } + } + + const [row] = await tx + .insert(folder) + .values({ + id: folderId, + resourceType: 'workflow', + name: params.name.trim(), + userId: params.userId, + workspaceId: params.workspaceId, + parentId, + sortOrder, + }) + .returning() + return row + }) logger.info('Created workflow folder', { folderId, workspaceId: params.workspaceId, parentId }) @@ -158,19 +164,32 @@ export async function performCreateFolder( action: AuditAction.FOLDER_CREATED, resourceType: AuditResourceType.FOLDER, resourceId: folderId, - resourceName: folder.name, - description: `Created folder "${folder.name}"`, + resourceName: createdFolder.name, + description: `Created folder "${createdFolder.name}"`, metadata: { - name: folder.name, + name: createdFolder.name, workspaceId: params.workspaceId, parentId: parentId || undefined, - color: folder.color, - sortOrder: folder.sortOrder, + sortOrder: createdFolder.sortOrder, }, }) - return { success: true, folder } + return { success: true, folder: createdFolder } } catch (error) { + // Propagate uncaught so the route's ResourceLockedError -> 423 handling applies. + if (error instanceof ResourceLockedError) { + throw error + } + if (error instanceof WorkflowFolderParentNotFoundError) { + return { success: false, error: error.message, errorCode: 'validation' } + } + if (getPostgresErrorCode(error) === '23505') { + return { + success: false, + error: 'A folder with this name already exists in this location', + errorCode: 'conflict', + } + } logger.error('Failed to create workflow folder', { error }) return { success: false, error: 'Internal server error', errorCode: 'internal' } } @@ -185,10 +204,13 @@ export async function performUpdateFolder( } if (params.parentId) { - const parentError = await assertParentFolderInWorkspace(params.parentId, params.workspaceId) + const parentError = await assertFolderParentValid(params.parentId, { + workspaceId: params.workspaceId, + resourceType: 'workflow', + }) if (parentError) return { success: false, ...parentError } - const wouldCreateCycle = await checkForCircularReference(params.folderId, params.parentId) + const wouldCreateCycle = await checkFolderCircularReference(params.folderId, params.parentId) if (wouldCreateCycle) { return { success: false, @@ -200,31 +222,108 @@ export async function performUpdateFolder( const updates: Record = { updatedAt: new Date() } if (params.name !== undefined) updates.name = params.name.trim() - if (params.color !== undefined) updates.color = params.color - if (params.isExpanded !== undefined) updates.isExpanded = params.isExpanded if (params.locked !== undefined) updates.locked = params.locked if (params.parentId !== undefined) updates.parentId = params.parentId || null if (params.sortOrder !== undefined) updates.sortOrder = params.sortOrder - const [folder] = await db - .update(workflowFolder) - .set(updates) - .where( - and( - eq(workflowFolder.id, params.folderId), - eq(workflowFolder.workspaceId, params.workspaceId) + const isLockOnlyUpdate = + params.name === undefined && params.parentId === undefined && params.sortOrder === undefined + + const updatedFolder = await db.transaction(async (tx) => { + // The route checks lock state before calling this function, but that's a + // separate round-trip -- an admin could lock this folder (or its target + // parent) in the window between that check and this transaction. Re-check + // inside the transaction (joining `tx` so the read is part of the same + // atomic unit as the write below) before applying anything. + // + // `assertFolderMutableUnlessUnlocking` only treats this folder's own + // (about-to-be-cleared) lock as satisfied when this same request unlocks it -- + // a lock inherited from an ancestor still blocks. Skipped entirely for a + // lock-only update, matching the file/kb/table lock-toggle precedent. + if (!isLockOnlyUpdate) { + await assertFolderMutableUnlessUnlocking( + params.folderId, + 'workflow', + params.locked === false, + tx ) - ) - .returning() + } + if (params.parentId) { + // A folder in an unlocked location could otherwise be moved into a locked one. + await assertFolderMutable(params.parentId, 'workflow', tx) + + // assertFolderMutable's lock walker treats a deleted parent as unlocked (it + // stops the ancestor walk when the row is missing, rather than erroring), so + // it alone doesn't catch a parent deleted between the earlier + // assertFolderParentValid precheck and this transaction. FOR UPDATE here + // makes the recheck race-free: once held, the parent's deletedAt can't + // change until this transaction resolves. + const [targetParent] = await tx + .select({ id: folder.id }) + .from(folder) + .where(and(eq(folder.id, params.parentId), isWorkflowFolder, isNull(folder.deletedAt))) + .for('update') + .limit(1) + if (!targetParent) { + throw new WorkflowFolderParentNotFoundError('Parent folder not found') + } + + // The cycle check above (`checkFolderCircularReference`) ran against an + // unlocked pre-transaction snapshot -- two concurrent moves (e.g. A under B + // and B under A) can each pass that stale check and then commit opposite + // parent updates, persisting a cycle. Re-run it here against `tx` so it + // observes the same FOR-UPDATE-locked parent chain this transaction is + // about to write. + const wouldCreateCycleInTx = await checkFolderCircularReference( + params.folderId, + params.parentId, + tx + ) + if (wouldCreateCycleInTx) { + throw new WorkflowFolderCycleError('Cannot create circular folder reference') + } + } + + const [row] = await tx + .update(folder) + .set(updates) + .where( + and( + eq(folder.id, params.folderId), + eq(folder.workspaceId, params.workspaceId), + isWorkflowFolder, + isNull(folder.deletedAt) + ) + ) + .returning() + return row + }) - if (!folder) { + if (!updatedFolder) { return { success: false, error: 'Folder not found', errorCode: 'not_found' } } logger.info('Updated workflow folder', { folderId: params.folderId, updates }) - return { success: true, folder } + return { success: true, folder: updatedFolder } } catch (error) { + // Propagate uncaught so the route's ResourceLockedError -> 423 handling applies. + if (error instanceof ResourceLockedError) { + throw error + } + if (error instanceof WorkflowFolderParentNotFoundError) { + return { success: false, error: error.message, errorCode: 'validation' } + } + if (error instanceof WorkflowFolderCycleError) { + return { success: false, error: error.message, errorCode: 'validation' } + } + if (getPostgresErrorCode(error) === '23505') { + return { + success: false, + error: 'A folder with this name already exists in this location', + errorCode: 'conflict', + } + } logger.error('Failed to update workflow folder', { error }) return { success: false, error: 'Internal server error', errorCode: 'internal' } } @@ -232,25 +331,33 @@ export async function performUpdateFolder( /** * Recursively deletes a folder: removes child folders first, archives non-archived - * workflows in each folder via {@link archiveWorkflowsByIdsInWorkspace}, then deletes + * workflows in each folder via {@link archiveWorkflowsByIdsInWorkspace}, then soft-deletes * the folder row. */ async function deleteFolderRecursively( folderId: string, workspaceId: string, - archivedAt?: Date + deletedAt?: Date ): Promise<{ folders: number; workflows: number }> { - const timestamp = archivedAt ?? new Date() + const timestamp = deletedAt ?? new Date() const stats = { folders: 0, workflows: 0 } + // Checked before recursing into children or archiving anything in this folder -- + // without this, a lock placed on `folderId` itself right as the cascade begins + // would only be caught by the tx-wrapped recheck at the end of this function, + // after descendants have already been soft-deleted/archived, leaving them + // deleted while this (locked) folder itself survives. + await assertFolderMutable(folderId, 'workflow') + const childFolders = await db - .select({ id: workflowFolder.id }) - .from(workflowFolder) + .select({ id: folder.id }) + .from(folder) .where( and( - eq(workflowFolder.parentId, folderId), - eq(workflowFolder.workspaceId, workspaceId), - isNull(workflowFolder.archivedAt) + eq(folder.parentId, folderId), + eq(folder.workspaceId, workspaceId), + isWorkflowFolder, + isNull(folder.deletedAt) ) ) @@ -280,10 +387,19 @@ async function deleteFolderRecursively( stats.workflows += workflowsInFolder.length } - await db - .update(workflowFolder) - .set({ archivedAt: timestamp }) - .where(eq(workflowFolder.id, folderId)) + // The route checks lock state before calling this function, but that's a + // separate round-trip, and this recursive cascade makes several of its own + // round-trips per folder besides -- an admin could lock this specific folder + // in the window before this write. A single, tightly-scoped transaction + // around just the recheck and this folder's own write closes that race for + // this row (the cascade as a whole still isn't one atomic unit, since + // archiveWorkflowsByIdsInWorkspace above doesn't accept a `tx`, but each + // folder's own delete now can't land after a lock was set for that folder + // specifically). + await db.transaction(async (tx) => { + await assertFolderMutable(folderId, 'workflow', tx) + await tx.update(folder).set({ deletedAt: timestamp }).where(eq(folder.id, folderId)) + }) stats.folders += 1 return stats @@ -312,13 +428,14 @@ async function countWorkflowsInFolderRecursively( count += workflowsInFolder.length const childFolders = await db - .select({ id: workflowFolder.id }) - .from(workflowFolder) + .select({ id: folder.id }) + .from(folder) .where( and( - eq(workflowFolder.parentId, folderId), - eq(workflowFolder.workspaceId, workspaceId), - isNull(workflowFolder.archivedAt) + eq(folder.parentId, folderId), + eq(folder.workspaceId, workspaceId), + isWorkflowFolder, + isNull(folder.deletedAt) ) ) @@ -395,18 +512,18 @@ export async function performDeleteFolder( /** * Recursively restores a folder and its children/workflows within a transaction. - * Only restores workflows whose `archivedAt` matches the folder's — workflows - * individually deleted before the folder are left archived. + * Only restores workflows whose `archivedAt` matches the folder's `deletedAt` — + * workflows individually deleted before the folder are left archived. */ async function restoreFolderRecursively( folderId: string, workspaceId: string, - folderArchivedAt: Date, + folderDeletedAt: Date, tx: Parameters[0]>[0] ): Promise<{ folders: number; workflows: number }> { const stats = { folders: 0, workflows: 0 } - await tx.update(workflowFolder).set({ archivedAt: null }).where(eq(workflowFolder.id, folderId)) + await tx.update(folder).set({ deletedAt: null }).where(eq(folder.id, folderId)) stats.folders += 1 const archivedWorkflows = await tx @@ -416,7 +533,7 @@ async function restoreFolderRecursively( and( eq(workflow.folderId, folderId), eq(workflow.workspaceId, workspaceId), - eq(workflow.archivedAt, folderArchivedAt) + eq(workflow.archivedAt, folderDeletedAt) ) ) @@ -441,18 +558,19 @@ async function restoreFolderRecursively( } const archivedChildren = await tx - .select({ id: workflowFolder.id }) - .from(workflowFolder) + .select({ id: folder.id }) + .from(folder) .where( and( - eq(workflowFolder.parentId, folderId), - eq(workflowFolder.workspaceId, workspaceId), - eq(workflowFolder.archivedAt, folderArchivedAt) + eq(folder.parentId, folderId), + eq(folder.workspaceId, workspaceId), + isWorkflowFolder, + eq(folder.deletedAt, folderDeletedAt) ) ) for (const child of archivedChildren) { - const childStats = await restoreFolderRecursively(child.id, workspaceId, folderArchivedAt, tx) + const childStats = await restoreFolderRecursively(child.id, workspaceId, folderDeletedAt, tx) stats.folders += childStats.folders stats.workflows += childStats.workflows } @@ -472,56 +590,97 @@ export interface PerformRestoreFolderParams { export interface PerformRestoreFolderResult { success: boolean error?: string + errorCode?: OrchestrationErrorCode restoredItems?: { folders: number; workflows: number } } /** - * Restores an archived folder and all its archived children and workflows. - * If the folder's parent is still archived, moves it to the root level. + * Restores a soft-deleted folder and all its soft-deleted children and workflows. + * If the folder's parent is still soft-deleted, moves it to the root level. */ export async function performRestoreFolder( params: PerformRestoreFolderParams ): Promise { const { folderId, workspaceId, userId, folderName } = params - const [folder] = await db - .select() - .from(workflowFolder) - .where(and(eq(workflowFolder.id, folderId), eq(workflowFolder.workspaceId, workspaceId))) - - if (!folder) { - return { success: false, error: 'Folder not found' } - } - - if (!folder.archivedAt) { - return { success: true, restoredItems: { folders: 0, workflows: 0 } } - } - const { getWorkspaceWithOwner } = await import('@/lib/workspaces/permissions/utils') const ws = await getWorkspaceWithOwner(workspaceId) if (!ws || ws.archivedAt) { - return { success: false, error: 'Cannot restore folder into an archived workspace' } + return { + success: false, + error: 'Cannot restore folder into an archived workspace', + errorCode: 'validation', + } } - const restoredStats = await db.transaction(async (tx) => { - if (folder.parentId) { + const outcome = await db.transaction(async (tx) => { + // `FOR UPDATE` row-locks this folder for the rest of the transaction -- a plain + // SELECT inside a transaction does not block a concurrent UPDATE, so without this + // a lock toggled on this row after this read but before the restore write below + // could still be silently bypassed. + const [existingFolder] = await tx + .select() + .from(folder) + .where(and(eq(folder.id, folderId), eq(folder.workspaceId, workspaceId), isWorkflowFolder)) + .for('update') + + if (!existingFolder) return { kind: 'not_found' as const } + if (!existingFolder.deletedAt) return { kind: 'not_archived' as const } + + // The folder row is soft-deleted, so the generic lock engine (which only reads + // active rows) can't see it -- check its own `locked` flag directly. This read + // must happen inside the same transaction as the restore write below (not before + // it opens): otherwise a concurrent lock landing between the read and the write + // could still resurrect and mutate a folder that is now locked. + if (existingFolder.locked) { + throw new ResourceLockedError('workflow', false, 'Folder is locked') + } + + let resolvedParentId = existingFolder.parentId + if (resolvedParentId) { + // FOR UPDATE closes the window where a concurrent transaction soft-deletes + // this parent between this read and the write below -- without it, this + // plain read isn't locked, so the parent could be deleted out from under us + // after we've already decided it's active, restoring this folder under a + // parent that no longer exists. const [parentFolder] = await tx - .select({ archivedAt: workflowFolder.archivedAt }) - .from(workflowFolder) - .where(eq(workflowFolder.id, folder.parentId)) - - if (!parentFolder || parentFolder.archivedAt) { - await tx - .update(workflowFolder) - .set({ parentId: null }) - .where(eq(workflowFolder.id, folderId)) + .select({ deletedAt: folder.deletedAt }) + .from(folder) + .where(eq(folder.id, resolvedParentId)) + .for('update') + + if (!parentFolder || parentFolder.deletedAt) { + resolvedParentId = null + await tx.update(folder).set({ parentId: null }).where(eq(folder.id, folderId)) } } - return restoreFolderRecursively(folderId, workspaceId, folder.archivedAt!, tx) + // resolvedParentId is either null (root, always safe) or a confirmed-active + // folder -- without this, the folder could resurface under a folder that was + // locked after this one was deleted. Passing `tx` (not the default db client) + // keeps this read inside the same transaction as the write below, closing the + // TOCTOU window where a concurrent request locks resolvedParentId in between. + await assertFolderMutable(resolvedParentId, 'workflow', tx) + + const stats = await restoreFolderRecursively( + folderId, + workspaceId, + existingFolder.deletedAt, + tx + ) + return { kind: 'ok' as const, stats, name: existingFolder.name } }) - logger.info('Restored folder and all contents:', { folderId, restoredStats }) + if (outcome.kind === 'not_found') { + return { success: false, error: 'Folder not found', errorCode: 'not_found' } + } + if (outcome.kind === 'not_archived') { + return { success: false, error: 'Folder is not archived', errorCode: 'validation' } + } + + const { stats, name } = outcome + + logger.info('Restored folder and all contents:', { folderId, restoredStats: stats }) recordAudit({ workspaceId, @@ -529,15 +688,15 @@ export async function performRestoreFolder( action: AuditAction.FOLDER_RESTORED, resourceType: AuditResourceType.FOLDER, resourceId: folderId, - resourceName: folderName ?? folder.name, - description: `Restored folder "${folderName ?? folder.name}"`, + resourceName: folderName ?? name, + description: `Restored folder "${folderName ?? name}"`, metadata: { affected: { - workflows: restoredStats.workflows, - subfolders: restoredStats.folders - 1, + workflows: stats.workflows, + subfolders: stats.folders - 1, }, }, }) - return { success: true, restoredItems: restoredStats } + return { success: true, restoredItems: stats } } diff --git a/apps/sim/lib/workflows/orchestration/types.ts b/apps/sim/lib/workflows/orchestration/types.ts index 03dd0050dca..70c715ffb2c 100644 --- a/apps/sim/lib/workflows/orchestration/types.ts +++ b/apps/sim/lib/workflows/orchestration/types.ts @@ -7,5 +7,6 @@ export type OrchestrationErrorCode = 'validation' | 'not_found' | 'conflict' | ' export function statusForOrchestrationError(code: OrchestrationErrorCode | undefined): number { if (code === 'validation') return 400 if (code === 'not_found') return 404 + if (code === 'conflict') return 409 return 500 } diff --git a/apps/sim/lib/workflows/orchestration/workflow-lifecycle.ts b/apps/sim/lib/workflows/orchestration/workflow-lifecycle.ts index 15917517fd4..cb56fe411ef 100644 --- a/apps/sim/lib/workflows/orchestration/workflow-lifecycle.ts +++ b/apps/sim/lib/workflows/orchestration/workflow-lifecycle.ts @@ -1,8 +1,17 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' -import { workflow, workflowFolder } from '@sim/db/schema' +import { folder, workflow } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { isFolderInWorkspace } from '@sim/platform-authz/workflow' +import { + getFolderLockStatus, + getResourceLockStatus, + ResourceLockedError, +} from '@sim/platform-authz/resource-lock' +import { + FolderLockedError, + isFolderInWorkspace, + WorkflowLockedError, +} from '@sim/platform-authz/workflow' import { toError } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { and, eq, isNull, min, ne } from 'drizzle-orm' @@ -116,9 +125,7 @@ async function nextWorkflowSortOrder( const workflowParentCondition = folderId ? eq(workflow.folderId, folderId) : isNull(workflow.folderId) - const folderParentCondition = folderId - ? eq(workflowFolder.parentId, folderId) - : isNull(workflowFolder.parentId) + const folderParentCondition = folderId ? eq(folder.parentId, folderId) : isNull(folder.parentId) const [[workflowMinResult], [folderMinResult]] = await Promise.all([ db @@ -132,9 +139,15 @@ async function nextWorkflowSortOrder( ) ), db - .select({ minOrder: min(workflowFolder.sortOrder) }) - .from(workflowFolder) - .where(and(eq(workflowFolder.workspaceId, workspaceId), folderParentCondition)), + .select({ minOrder: min(folder.sortOrder) }) + .from(folder) + .where( + and( + eq(folder.workspaceId, workspaceId), + eq(folder.resourceType, 'workflow'), + folderParentCondition + ) + ), ]) const minSortOrder = [workflowMinResult?.minOrder, folderMinResult?.minOrder].reduce< @@ -316,22 +329,62 @@ export async function performUpdateWorkflow( if (params.sortOrder !== undefined) updateData.sortOrder = params.sortOrder if (params.locked !== undefined) updateData.locked = params.locked - const [updatedWorkflow] = await db - .update(workflow) - .set(updateData) - .where(eq(workflow.id, params.workflowId)) - .returning({ - id: workflow.id, - name: workflow.name, - description: workflow.description, - workspaceId: workflow.workspaceId, - folderId: workflow.folderId, - sortOrder: workflow.sortOrder, - locked: workflow.locked, - createdAt: workflow.createdAt, - updatedAt: workflow.updatedAt, - archivedAt: workflow.archivedAt, - }) + // A lock-only update (`{ locked }` and nothing else) never touches the workflow's + // own lock gate -- mirrors the route's `hasNonLockUpdate` gate exactly, so this + // in-transaction recheck doesn't change observable behavior, only closes the race. + const hasNonLockUpdate = + params.name !== undefined || + params.description !== undefined || + params.folderId !== undefined || + params.sortOrder !== undefined + + const updatedWorkflow = await db.transaction(async (trx) => { + // The route checks lock state (`assertWorkflowMutable`/`assertFolderMutable`) + // before calling this function, but that's a separate round-trip against the + // default `db` client -- an admin could lock this workflow (or its target + // folder) in the window between that check and this write. Re-check inside the + // transaction (joining `trx`) before applying anything. + if (hasNonLockUpdate) { + const status = await getResourceLockStatus('workflow', params.workflowId, trx) + if (status.locked) { + throw new WorkflowLockedError( + status.lockedBy === 'folder' + ? 'Workflow is locked by its containing folder' + : 'Workflow is locked', + status.lockedBy === 'folder' + ) + } + } + if (params.folderId !== undefined) { + const targetStatus = await getFolderLockStatus(targetFolderId, 'workflow', trx) + if (targetStatus.locked) { + throw new FolderLockedError( + targetStatus.inheritedLocked + ? 'Folder is locked by an ancestor folder' + : 'Folder is locked', + targetStatus.inheritedLocked + ) + } + } + + const [row] = await trx + .update(workflow) + .set(updateData) + .where(eq(workflow.id, params.workflowId)) + .returning({ + id: workflow.id, + name: workflow.name, + description: workflow.description, + workspaceId: workflow.workspaceId, + folderId: workflow.folderId, + sortOrder: workflow.sortOrder, + locked: workflow.locked, + createdAt: workflow.createdAt, + updatedAt: workflow.updatedAt, + archivedAt: workflow.archivedAt, + }) + return row + }) if (!updatedWorkflow) { return { success: false, error: 'Workflow not found', errorCode: 'not_found' } @@ -368,6 +421,11 @@ export async function performUpdateWorkflow( return { success: true, workflow: updatedWorkflow } } catch (error) { + // Propagate uncaught so the route's WorkflowLockedError/FolderLockedError -> 423 + // handling applies, instead of being swallowed into a generic 500 below. + if (error instanceof ResourceLockedError) { + throw error + } logger.error(`[${requestId}] Failed to update workflow ${params.workflowId}`, { error }) return { success: false, error: toError(error).message, errorCode: 'internal' } } diff --git a/apps/sim/lib/workflows/persistence/duplicate.ts b/apps/sim/lib/workflows/persistence/duplicate.ts index ab075a8be5f..9b8eb5230d7 100644 --- a/apps/sim/lib/workflows/persistence/duplicate.ts +++ b/apps/sim/lib/workflows/persistence/duplicate.ts @@ -3,7 +3,7 @@ import { workflow, workflowBlocks, workflowEdges, - workflowFolder, + folder as workflowFolder, workflowSubflows, } from '@sim/db/schema' import { createLogger } from '@sim/logger' @@ -67,29 +67,41 @@ async function assertTargetFolderMutable( targetWorkspaceId: string ): Promise { let currentFolderId = folderId + let isDirect = true const visited = new Set() while (currentFolderId && !visited.has(currentFolderId)) { visited.add(currentFolderId) + // Scoped to resourceType='workflow' and row-locked (`FOR UPDATE`) for the rest of + // this transaction -- without the resourceType filter, a folder id belonging to a + // file/knowledge_base/table folder tree would pass this check, letting a + // duplicated workflow be reparented into a folder of the wrong resource type. const [folder] = await tx .select({ id: workflowFolder.id, parentId: workflowFolder.parentId, workspaceId: workflowFolder.workspaceId, locked: workflowFolder.locked, - archivedAt: workflowFolder.archivedAt, + deletedAt: workflowFolder.deletedAt, }) .from(workflowFolder) - .where(eq(workflowFolder.id, currentFolderId)) + .where( + and(eq(workflowFolder.id, currentFolderId), eq(workflowFolder.resourceType, 'workflow')) + ) + .for('update') .limit(1) - if (!folder || folder.workspaceId !== targetWorkspaceId || folder.archivedAt) { + if (!folder || folder.workspaceId !== targetWorkspaceId || folder.deletedAt) { throw new Error('Target folder not found') } if (folder.locked) { - throw new FolderLockedError() + throw new FolderLockedError( + isDirect ? 'Folder is locked' : 'Folder is locked by an ancestor folder', + !isDirect + ) } currentFolderId = folder.parentId + isDirect = false } } @@ -193,7 +205,13 @@ export async function duplicateWorkflow( tx .select({ minOrder: min(workflowFolder.sortOrder) }) .from(workflowFolder) - .where(and(eq(workflowFolder.workspaceId, targetWorkspaceId), folderParentCondition)), + .where( + and( + eq(workflowFolder.workspaceId, targetWorkspaceId), + eq(workflowFolder.resourceType, 'workflow'), + folderParentCondition + ) + ), ]) const minSortOrder = [workflowMinResult?.minOrder, folderMinResult?.minOrder].reduce< number | null diff --git a/apps/sim/lib/workflows/utils.ts b/apps/sim/lib/workflows/utils.ts index ced3f838e93..fe6ce4005e4 100644 --- a/apps/sim/lib/workflows/utils.ts +++ b/apps/sim/lib/workflows/utils.ts @@ -1,9 +1,9 @@ import { db } from '@sim/db' -import { workflowFolder, workflow as workflowTable } from '@sim/db/schema' +import { folder as workflowFolder, workflow as workflowTable } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { authorizeWorkflowByWorkspacePermission } from '@sim/platform-authz/workflow' import { generateId } from '@sim/utils/id' -import { and, asc, eq, inArray, isNull, max, min, sql } from 'drizzle-orm' +import { and, asc, eq, inArray, isNull, min, sql } from 'drizzle-orm' import { NextResponse } from 'next/server' import { getSession } from '@/lib/auth' import { ensureWorkflowAliasBacking } from '@/lib/copilot/vfs/workflow-alias-backing' @@ -418,7 +418,13 @@ export async function createWorkflowRecord(params: CreateWorkflowInput) { db .select({ minOrder: min(workflowFolder.sortOrder) }) .from(workflowFolder) - .where(and(eq(workflowFolder.workspaceId, workspaceId), folderParentCondition)), + .where( + and( + eq(workflowFolder.workspaceId, workspaceId), + eq(workflowFolder.resourceType, 'workflow'), + folderParentCondition + ) + ), ]) const minSortOrder = [workflowMinResult?.minOrder, folderMinResult?.minOrder].reduce< @@ -486,52 +492,6 @@ export async function setWorkflowVariables(workflowId: string, variables: Record // ── Folder CRUD ── -export interface CreateFolderInput { - userId: string - workspaceId: string - name: string - parentId?: string | null -} - -export async function createFolderRecord(params: CreateFolderInput) { - const { userId, workspaceId, name, parentId = null } = params - - const [maxResult] = await db - .select({ maxOrder: max(workflowFolder.sortOrder) }) - .from(workflowFolder) - .where( - and( - eq(workflowFolder.workspaceId, workspaceId), - parentId ? eq(workflowFolder.parentId, parentId) : isNull(workflowFolder.parentId) - ) - ) - const sortOrder = (maxResult?.maxOrder ?? 0) + 1 - - const folderId = generateId() - await db.insert(workflowFolder).values({ - id: folderId, - userId, - workspaceId, - parentId, - name, - sortOrder, - createdAt: new Date(), - updatedAt: new Date(), - }) - - return { folderId, name, workspaceId, parentId } -} - -export async function updateFolderRecord( - folderId: string, - updates: { name?: string; parentId?: string | null } -) { - const setData: Record = { updatedAt: new Date() } - if (updates.name !== undefined) setData.name = updates.name - if (updates.parentId !== undefined) setData.parentId = updates.parentId - await db.update(workflowFolder).set(setData).where(eq(workflowFolder.id, folderId)) -} - export async function verifyFolderWorkspace( folderId: string, workspaceId: string @@ -539,65 +499,17 @@ export async function verifyFolderWorkspace( const [row] = await db .select({ id: workflowFolder.id }) .from(workflowFolder) - .where(and(eq(workflowFolder.id, folderId), eq(workflowFolder.workspaceId, workspaceId))) + .where( + and( + eq(workflowFolder.id, folderId), + eq(workflowFolder.workspaceId, workspaceId), + eq(workflowFolder.resourceType, 'workflow') + ) + ) .limit(1) return Boolean(row) } -export async function deleteFolderRecord(folderId: string): Promise { - const [folder] = await db - .select({ parentId: workflowFolder.parentId }) - .from(workflowFolder) - .where(eq(workflowFolder.id, folderId)) - .limit(1) - - if (!folder) return false - - await db - .update(workflowTable) - .set({ folderId: folder.parentId, updatedAt: new Date() }) - .where(eq(workflowTable.folderId, folderId)) - - await db - .update(workflowFolder) - .set({ parentId: folder.parentId, updatedAt: new Date() }) - .where(eq(workflowFolder.parentId, folderId)) - - await db.delete(workflowFolder).where(eq(workflowFolder.id, folderId)) - - return true -} - -/** - * Checks whether setting `parentId` as the parent of `folderId` would - * create a circular reference in the folder tree. - */ -export async function checkForCircularReference( - folderId: string, - parentId: string -): Promise { - let currentParentId: string | null = parentId - const visited = new Set() - - while (currentParentId) { - if (visited.has(currentParentId) || currentParentId === folderId) { - return true - } - - visited.add(currentParentId) - - const [parent] = await db - .select({ parentId: workflowFolder.parentId }) - .from(workflowFolder) - .where(eq(workflowFolder.id, currentParentId)) - .limit(1) - - currentParentId = parent?.parentId || null - } - - return false -} - export async function listFolders(workspaceId: string) { return db .select({ @@ -608,6 +520,12 @@ export async function listFolders(workspaceId: string) { locked: workflowFolder.locked, }) .from(workflowFolder) - .where(and(eq(workflowFolder.workspaceId, workspaceId), isNull(workflowFolder.archivedAt))) + .where( + and( + eq(workflowFolder.workspaceId, workspaceId), + eq(workflowFolder.resourceType, 'workflow'), + isNull(workflowFolder.deletedAt) + ) + ) .orderBy(asc(workflowFolder.sortOrder), asc(workflowFolder.createdAt)) } diff --git a/apps/sim/lib/workspace-files/orchestration/file-folder-lifecycle.test.ts b/apps/sim/lib/workspace-files/orchestration/file-folder-lifecycle.test.ts new file mode 100644 index 00000000000..68e025200b2 --- /dev/null +++ b/apps/sim/lib/workspace-files/orchestration/file-folder-lifecycle.test.ts @@ -0,0 +1,311 @@ +/** + * @vitest-environment node + * + * Resource-lock enforcement for the workspace-files orchestration layer. + * `performRenameWorkspaceFile` / `performDeleteWorkspaceFileItems` / + * `performMoveWorkspaceFileItems` all call `assertResourceMutable('file', id)` + * for each file id and `assertFolderMutable(id, 'file')` for each folder id + * before mutating — this guards a direct lock (`inherited: false`), a + * folder-inherited lock (`inherited: true`) surface as `errorCode: 'locked'` + * (423 via `workspaceFilesOrchestrationStatus`), and that an unrelated + * (non-lock) update still goes through the lock check. + */ +import { ResourceLockedError } from '@sim/platform-authz/resource-lock' +import { auditMock, resourceLockMockFns } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockBulkArchiveWorkspaceFileItems, mockMoveWorkspaceFileItems, mockRenameWorkspaceFile } = + vi.hoisted(() => ({ + mockBulkArchiveWorkspaceFileItems: vi.fn(), + mockMoveWorkspaceFileItems: vi.fn(), + mockRenameWorkspaceFile: vi.fn(), + })) + +vi.mock('@sim/audit', () => auditMock) + +vi.mock('@/lib/folders/orchestration', () => ({ + performCreateFolder: vi.fn(), + performRestoreFolder: vi.fn(), + performUpdateFolder: vi.fn(), +})) + +vi.mock('@/lib/uploads/contexts/workspace', () => { + class FileConflictErrorStub extends Error {} + class WorkspaceFileFolderConflictErrorStub extends Error {} + class WorkspaceFileItemsNotFoundErrorStub extends Error {} + class WorkspaceFileMoveConflictErrorStub extends Error {} + return { + bulkArchiveWorkspaceFileItems: mockBulkArchiveWorkspaceFileItems, + moveWorkspaceFileItems: mockMoveWorkspaceFileItems, + renameWorkspaceFile: mockRenameWorkspaceFile, + restoreWorkspaceFile: vi.fn(), + getWorkspaceFileFolder: vi.fn(), + FileConflictError: FileConflictErrorStub, + WorkspaceFileFolderConflictError: WorkspaceFileFolderConflictErrorStub, + WorkspaceFileItemsNotFoundError: WorkspaceFileItemsNotFoundErrorStub, + WorkspaceFileMoveConflictError: WorkspaceFileMoveConflictErrorStub, + } +}) + +import { + performDeleteWorkspaceFileItems, + performMoveWorkspaceFileItems, + performRenameWorkspaceFile, + workspaceFilesOrchestrationStatus, +} from '@/lib/workspace-files/orchestration/file-folder-lifecycle' + +describe('workspace-files orchestration — resource-lock enforcement', () => { + beforeEach(() => { + vi.clearAllMocks() + resourceLockMockFns.mockAssertResourceMutable.mockReset() + resourceLockMockFns.mockAssertFolderMutable.mockReset() + resourceLockMockFns.mockAssertResourceMutable.mockResolvedValue(undefined) + resourceLockMockFns.mockAssertFolderMutable.mockResolvedValue(undefined) + }) + + describe('performRenameWorkspaceFile', () => { + it('returns a 423 (locked) when the file itself is directly locked', async () => { + resourceLockMockFns.mockAssertResourceMutable.mockRejectedValueOnce( + new ResourceLockedError('file', false, 'File is locked') + ) + + const result = await performRenameWorkspaceFile({ + workspaceId: 'ws-1', + fileId: 'file-1', + name: 'renamed.txt', + userId: 'user-1', + }) + + expect(result).toMatchObject({ success: false, errorCode: 'locked' }) + expect(workspaceFilesOrchestrationStatus(result.errorCode)).toBe(423) + expect(resourceLockMockFns.mockAssertResourceMutable).toHaveBeenCalledWith('file', 'file-1') + expect(mockRenameWorkspaceFile).not.toHaveBeenCalled() + }) + + it('returns a 423 (locked, inherited) when the file is inside a locked folder', async () => { + resourceLockMockFns.mockAssertResourceMutable.mockRejectedValueOnce( + new ResourceLockedError('file', true, 'File is locked by its containing folder') + ) + + const result = await performRenameWorkspaceFile({ + workspaceId: 'ws-1', + fileId: 'file-1', + name: 'renamed.txt', + userId: 'user-1', + }) + + expect(result).toMatchObject({ success: false, errorCode: 'locked' }) + expect(mockRenameWorkspaceFile).not.toHaveBeenCalled() + }) + + it('enforces the lock check on an unrelated (non-lock) rename', async () => { + mockRenameWorkspaceFile.mockResolvedValueOnce({ id: 'file-1', name: 'renamed.txt' }) + + const result = await performRenameWorkspaceFile({ + workspaceId: 'ws-1', + fileId: 'file-1', + name: 'renamed.txt', + userId: 'user-1', + }) + + expect(result.success).toBe(true) + expect(resourceLockMockFns.mockAssertResourceMutable).toHaveBeenCalledWith('file', 'file-1') + expect(mockRenameWorkspaceFile).toHaveBeenCalledTimes(1) + }) + + it('skips the lock check for a lock-only update', async () => { + mockRenameWorkspaceFile.mockResolvedValueOnce({ id: 'file-1', name: 'file-1.txt' }) + + await performRenameWorkspaceFile({ + workspaceId: 'ws-1', + fileId: 'file-1', + name: 'file-1.txt', + userId: 'user-1', + locked: false, + isLockOnlyUpdate: true, + }) + + expect(resourceLockMockFns.mockAssertResourceMutable).not.toHaveBeenCalled() + }) + + it('allows unlocking a directly-locked file combined with a rename in the same request', async () => { + // Regression test: isLockOnlyUpdate is false whenever the name also changes, so + // a combined "unlock + rename" request previously still ran + // assertResourceMutable against the file's current (still-locked) state and was + // incorrectly rejected, even though the request unlocks it as part of this same + // atomic write. The fixed behavior still runs the check (so an inherited lock + // is caught below), but treats a DIRECT lock as satisfied since this request + // clears it. + mockRenameWorkspaceFile.mockResolvedValueOnce({ id: 'file-1', name: 'renamed.txt' }) + resourceLockMockFns.mockAssertResourceMutable.mockRejectedValueOnce( + new ResourceLockedError('file', false, 'File is locked') + ) + + await performRenameWorkspaceFile({ + workspaceId: 'ws-1', + fileId: 'file-1', + name: 'renamed.txt', + userId: 'user-1', + locked: false, + isLockOnlyUpdate: false, + }) + + expect(resourceLockMockFns.mockAssertResourceMutable).toHaveBeenCalledWith('file', 'file-1') + expect(mockRenameWorkspaceFile).toHaveBeenCalledTimes(1) + }) + + it('still rejects unlocking a file combined with a rename when the lock is inherited from its folder', async () => { + // Clearing the file's own `locked` flag doesn't affect a lock inherited from + // its containing folder -- that must still block the combined request. + resourceLockMockFns.mockAssertResourceMutable.mockRejectedValueOnce( + new ResourceLockedError('file', true, 'File is locked by its containing folder') + ) + + const result = await performRenameWorkspaceFile({ + workspaceId: 'ws-1', + fileId: 'file-1', + name: 'renamed.txt', + userId: 'user-1', + locked: false, + isLockOnlyUpdate: false, + }) + + expect(result).toMatchObject({ success: false, errorCode: 'locked' }) + expect(mockRenameWorkspaceFile).not.toHaveBeenCalled() + }) + }) + + describe('performDeleteWorkspaceFileItems', () => { + it('returns a 423 (locked) when a targeted file is directly locked', async () => { + resourceLockMockFns.mockAssertResourceMutable.mockRejectedValueOnce( + new ResourceLockedError('file', false, 'File is locked') + ) + + const result = await performDeleteWorkspaceFileItems({ + workspaceId: 'ws-1', + userId: 'user-1', + fileIds: ['file-1'], + }) + + expect(result).toMatchObject({ success: false, errorCode: 'locked' }) + expect(workspaceFilesOrchestrationStatus(result.errorCode)).toBe(423) + expect(mockBulkArchiveWorkspaceFileItems).not.toHaveBeenCalled() + }) + + it('returns a 423 (locked, inherited) when a targeted folder is locked (bulk folderIds)', async () => { + resourceLockMockFns.mockAssertFolderMutable.mockRejectedValueOnce( + new ResourceLockedError('file', true, 'Folder is locked') + ) + + const result = await performDeleteWorkspaceFileItems({ + workspaceId: 'ws-1', + userId: 'user-1', + folderIds: ['folder-1'], + }) + + expect(result).toMatchObject({ success: false, errorCode: 'locked' }) + expect(resourceLockMockFns.mockAssertFolderMutable).toHaveBeenCalledWith('folder-1', 'file') + expect(mockBulkArchiveWorkspaceFileItems).not.toHaveBeenCalled() + }) + + it('enforces the lock check on an unrelated (non-lock) bulk delete', async () => { + mockBulkArchiveWorkspaceFileItems.mockResolvedValueOnce({ files: 1, folders: 0 }) + + const result = await performDeleteWorkspaceFileItems({ + workspaceId: 'ws-1', + userId: 'user-1', + fileIds: ['file-1'], + }) + + expect(result.success).toBe(true) + expect(resourceLockMockFns.mockAssertResourceMutable).toHaveBeenCalledWith('file', 'file-1') + expect(mockBulkArchiveWorkspaceFileItems).toHaveBeenCalledTimes(1) + }) + }) + + describe('performMoveWorkspaceFileItems', () => { + it('returns a 423 (locked) when a targeted file is directly locked', async () => { + resourceLockMockFns.mockAssertResourceMutable.mockRejectedValueOnce( + new ResourceLockedError('file', false, 'File is locked') + ) + + const result = await performMoveWorkspaceFileItems({ + workspaceId: 'ws-1', + userId: 'user-1', + fileIds: ['file-1'], + targetFolderId: 'folder-2', + }) + + expect(result).toMatchObject({ success: false, errorCode: 'locked' }) + expect(workspaceFilesOrchestrationStatus(result.errorCode)).toBe(423) + expect(mockMoveWorkspaceFileItems).not.toHaveBeenCalled() + }) + + it('returns a 423 (locked, inherited) when a targeted folder is locked (bulk folderIds)', async () => { + resourceLockMockFns.mockAssertFolderMutable.mockRejectedValueOnce( + new ResourceLockedError('file', true, 'Folder is locked') + ) + + const result = await performMoveWorkspaceFileItems({ + workspaceId: 'ws-1', + userId: 'user-1', + folderIds: ['folder-1'], + targetFolderId: 'folder-2', + }) + + expect(result).toMatchObject({ success: false, errorCode: 'locked' }) + expect(resourceLockMockFns.mockAssertFolderMutable).toHaveBeenCalledWith('folder-1', 'file') + expect(mockMoveWorkspaceFileItems).not.toHaveBeenCalled() + }) + + it('enforces the lock check on an unrelated (non-lock) bulk move', async () => { + mockMoveWorkspaceFileItems.mockResolvedValueOnce({ movedFiles: 1, movedFolders: 0 }) + + const result = await performMoveWorkspaceFileItems({ + workspaceId: 'ws-1', + userId: 'user-1', + fileIds: ['file-1'], + targetFolderId: 'folder-2', + }) + + expect(result.success).toBe(true) + expect(resourceLockMockFns.mockAssertResourceMutable).toHaveBeenCalledWith('file', 'file-1') + expect(mockMoveWorkspaceFileItems).toHaveBeenCalledTimes(1) + }) + + it('returns a 423 (locked) when the item is unlocked but the destination folder is locked', async () => { + // Regression test: the source-item/source-folder checks above only cover each + // moved item's *current* location -- without a separate check on targetFolderId, + // an unlocked item could be moved into a locked destination folder. + resourceLockMockFns.mockAssertFolderMutable.mockRejectedValueOnce( + new ResourceLockedError('file', false, 'Folder is locked') + ) + + const result = await performMoveWorkspaceFileItems({ + workspaceId: 'ws-1', + userId: 'user-1', + fileIds: ['file-1'], + targetFolderId: 'folder-2', + }) + + expect(result).toMatchObject({ success: false, errorCode: 'locked' }) + expect(resourceLockMockFns.mockAssertResourceMutable).toHaveBeenCalledWith('file', 'file-1') + expect(resourceLockMockFns.mockAssertFolderMutable).toHaveBeenCalledWith('folder-2', 'file') + expect(mockMoveWorkspaceFileItems).not.toHaveBeenCalled() + }) + + it('does not check destination lock status for a root move (no targetFolderId)', async () => { + mockMoveWorkspaceFileItems.mockResolvedValueOnce({ movedFiles: 1, movedFolders: 0 }) + + const result = await performMoveWorkspaceFileItems({ + workspaceId: 'ws-1', + userId: 'user-1', + fileIds: ['file-1'], + targetFolderId: null, + }) + + expect(result.success).toBe(true) + expect(resourceLockMockFns.mockAssertFolderMutable).not.toHaveBeenCalled() + }) + }) +}) diff --git a/apps/sim/lib/workspace-files/orchestration/file-folder-lifecycle.ts b/apps/sim/lib/workspace-files/orchestration/file-folder-lifecycle.ts index 30379de8031..e89fb90da0d 100644 --- a/apps/sim/lib/workspace-files/orchestration/file-folder-lifecycle.ts +++ b/apps/sim/lib/workspace-files/orchestration/file-folder-lifecycle.ts @@ -1,15 +1,24 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' +import { + assertFolderMutable, + assertResourceMutable, + assertResourceMutableUnlessUnlocking, + ResourceLockedError, +} from '@sim/platform-authz/resource-lock' import { getPostgresErrorCode, toError } from '@sim/utils/errors' +import { + performCreateFolder, + performRestoreFolder, + performUpdateFolder, +} from '@/lib/folders/orchestration' import { bulkArchiveWorkspaceFileItems, - createWorkspaceFileFolder, FileConflictError, + getWorkspaceFileFolder, moveWorkspaceFileItems, renameWorkspaceFile, restoreWorkspaceFile, - restoreWorkspaceFileFolder, - updateWorkspaceFileFolder, type WorkspaceFileArchiveResult, WorkspaceFileFolderConflictError, type WorkspaceFileFolderRecord, @@ -24,6 +33,7 @@ export type WorkspaceFilesOrchestrationErrorCode = | 'validation' | 'not_found' | 'conflict' + | 'locked' | 'internal' export function workspaceFilesOrchestrationStatus( @@ -32,6 +42,7 @@ export function workspaceFilesOrchestrationStatus( if (errorCode === 'validation') return 400 if (errorCode === 'conflict') return 409 if (errorCode === 'not_found') return 404 + if (errorCode === 'locked') return 423 return 500 } @@ -69,6 +80,9 @@ export interface PerformRenameWorkspaceFileParams { fileId: string name: string userId: string + locked?: boolean + /** True when `name` is unchanged and only `locked` is being toggled. */ + isLockOnlyUpdate?: boolean } export interface PerformRenameWorkspaceFileResult { @@ -148,6 +162,9 @@ export async function performDeleteWorkspaceFileItems( } try { + await Promise.all(fileIds.map((id) => assertResourceMutable('file', id))) + await Promise.all(folderIds.map((id) => assertFolderMutable(id, 'file'))) + const deletedItems = await bulkArchiveWorkspaceFileItems({ workspaceId, fileIds, folderIds }) if (fileIds.length === 1 && folderIds.length === 0 && deletedItems.files === 0) { @@ -195,6 +212,9 @@ export async function performDeleteWorkspaceFileItems( return { success: true, deletedItems } } catch (error) { + if (error instanceof ResourceLockedError) { + return { success: false, error: error.message, errorCode: 'locked' } + } logger.error('Failed to delete workspace file items', { error }) return { success: false, error: toError(error).message, errorCode: 'internal' } } @@ -214,6 +234,14 @@ export async function performMoveWorkspaceFileItems( } try { + await Promise.all(fileIds.map((id) => assertResourceMutable('file', id))) + await Promise.all(folderIds.map((id) => assertFolderMutable(id, 'file'))) + // The checks above only cover each moved item's *current* folder chain — + // without this, an item could be moved out of an unlocked folder into a locked one. + if (targetFolderId) { + await assertFolderMutable(targetFolderId, 'file') + } + const moved = await moveWorkspaceFileItems({ workspaceId, fileIds, @@ -255,6 +283,9 @@ export async function performMoveWorkspaceFileItems( return { success: true, movedItems } } catch (error) { + if (error instanceof ResourceLockedError) { + return { success: false, error: error.message, errorCode: 'locked' } + } logger.error('Failed to move workspace file items', { error }) if ( error instanceof WorkspaceFileMoveConflictError || @@ -280,10 +311,19 @@ export async function performMoveWorkspaceFileItems( export async function performRenameWorkspaceFile( params: PerformRenameWorkspaceFileParams ): Promise { - const { workspaceId, fileId, name, userId } = params + const { workspaceId, fileId, name, userId, locked, isLockOnlyUpdate } = params try { - const file = await renameWorkspaceFile(workspaceId, fileId, name) + // An admin combining `locked: false` with a rename in one request is unlocking + // the file as part of this same atomic write -- the mutable-check must not + // treat that request's own current (about-to-be-cleared) lock as blocking. It + // must still enforce a lock inherited from the file's containing folder, since + // clearing the file's own `locked` flag doesn't affect that. + if (!isLockOnlyUpdate) { + await assertResourceMutableUnlessUnlocking('file', fileId, locked === false) + } + + const file = await renameWorkspaceFile(workspaceId, fileId, name, locked) logger.info('Renamed workspace file', { workspaceId, fileId, name: file.name }) @@ -299,6 +339,9 @@ export async function performRenameWorkspaceFile( return { success: true, file } } catch (error) { + if (error instanceof ResourceLockedError) { + return { success: false, error: error.message, errorCode: 'locked' } + } logger.error('Failed to rename workspace file', { error }) if (error instanceof FileConflictError || getPostgresErrorCode(error) === '23505') { return { success: false, error: toError(error).message, errorCode: 'conflict' } @@ -313,6 +356,7 @@ export async function performRestoreWorkspaceFile( const { workspaceId, fileId, userId } = params try { + await assertResourceMutable('file', fileId) await restoreWorkspaceFile(workspaceId, fileId) logger.info('Restored workspace file', { workspaceId, fileId }) @@ -329,6 +373,9 @@ export async function performRestoreWorkspaceFile( return { success: true } } catch (error) { + if (error instanceof ResourceLockedError) { + return { success: false, error: error.message, errorCode: 'locked' } + } logger.error('Failed to restore workspace file', { error }) if (error instanceof FileConflictError || getPostgresErrorCode(error) === '23505') { return { success: false, error: toError(error).message, errorCode: 'conflict' } @@ -337,121 +384,85 @@ export async function performRestoreWorkspaceFile( } } +/** + * Delegates to the generic `resourceType: 'file'` folder CRUD in + * `@/lib/folders/orchestration` (single source of truth for folder audit + * recording) and re-fetches the VFS-flavored `WorkspaceFileFolderRecord` + * (adds the computed `path` the generic `Folder` shape doesn't carry). + */ export async function performCreateWorkspaceFileFolder( params: PerformCreateWorkspaceFileFolderParams ): Promise { const { workspaceId, userId, name, parentId } = params - try { - const folder = await createWorkspaceFileFolder({ workspaceId, userId, name, parentId }) - - logger.info('Created workspace file folder', { workspaceId, folderId: folder.id }) - - recordAudit({ - workspaceId, - actorId: userId, - action: AuditAction.FOLDER_CREATED, - resourceType: AuditResourceType.FOLDER, - resourceId: folder.id, - resourceName: folder.name, - description: `Created file folder "${folder.name}"`, - }) + const result = await performCreateFolder({ + resourceType: 'file', + workspaceId, + userId, + name, + parentId, + }) + if (!result.success || !result.folder) { + return { success: false, error: result.error, errorCode: result.errorCode } + } - return { success: true, folder } - } catch (error) { - logger.error('Failed to create workspace file folder', { error }) - if ( - error instanceof WorkspaceFileFolderConflictError || - getPostgresErrorCode(error) === '23505' - ) { - return { success: false, error: toError(error).message, errorCode: 'conflict' } - } - return { success: false, error: toError(error).message, errorCode: 'internal' } + const folder = await getWorkspaceFileFolder(workspaceId, result.folder.id) + if (!folder) { + return { success: false, error: 'Folder not found', errorCode: 'not_found' } } + + logger.info('Created workspace file folder', { workspaceId, folderId: folder.id }) + return { success: true, folder } } +/** See `performCreateWorkspaceFileFolder` — delegates to the generic layer, re-fetches for `path`. */ export async function performUpdateWorkspaceFileFolder( params: PerformUpdateWorkspaceFileFolderParams ): Promise { const { workspaceId, folderId, userId, name, parentId, sortOrder } = params - try { - const folder = await updateWorkspaceFileFolder({ - workspaceId, - folderId, - name, - parentId, - sortOrder, - }) - - logger.info('Updated workspace file folder', { workspaceId, folderId }) - - recordAudit({ - workspaceId, - actorId: userId, - action: AuditAction.FOLDER_UPDATED, - resourceType: AuditResourceType.FOLDER, - resourceId: folderId, - resourceName: folder.name, - description: `Updated file folder "${folder.name}"`, - }) + const result = await performUpdateFolder({ + resourceType: 'file', + workspaceId, + folderId, + userId, + name, + parentId, + sortOrder, + }) + if (!result.success || !result.folder) { + return { success: false, error: result.error, errorCode: result.errorCode } + } - return { success: true, folder } - } catch (error) { - logger.error('Failed to update workspace file folder', { error }) - if ( - error instanceof WorkspaceFileFolderConflictError || - getPostgresErrorCode(error) === '23505' - ) { - return { - success: false, - error: - getPostgresErrorCode(error) === '23505' - ? 'A folder with this name already exists in this location' - : toError(error).message, - errorCode: 'conflict', - } - } - return { success: false, error: toError(error).message, errorCode: 'internal' } + const folder = await getWorkspaceFileFolder(workspaceId, folderId) + if (!folder) { + return { success: false, error: 'Folder not found', errorCode: 'not_found' } } + + logger.info('Updated workspace file folder', { workspaceId, folderId }) + return { success: true, folder } } +/** See `performCreateWorkspaceFileFolder` — delegates to the generic layer, re-fetches for `path`. */ export async function performRestoreWorkspaceFileFolder( params: PerformRestoreWorkspaceFileFolderParams ): Promise { const { workspaceId, folderId, userId } = params - try { - const { folder, restoredItems } = await restoreWorkspaceFileFolder(workspaceId, folderId) - - logger.info('Restored workspace file folder', { workspaceId, folderId, restoredItems }) + const result = await performRestoreFolder({ resourceType: 'file', workspaceId, folderId, userId }) + if (!result.success || !result.restoredItems) { + return { success: false, error: result.error, errorCode: result.errorCode } + } - recordAudit({ - workspaceId, - actorId: userId, - action: AuditAction.FOLDER_RESTORED, - resourceType: AuditResourceType.FOLDER, - resourceId: folderId, - resourceName: folder.name, - description: `Restored file folder "${folder.name}"`, - metadata: { - affected: { - files: restoredItems.files, - subfolders: Math.max(0, restoredItems.folders - 1), - }, - }, - }) + const folder = await getWorkspaceFileFolder(workspaceId, folderId) + if (!folder) { + return { success: false, error: 'Folder not found', errorCode: 'not_found' } + } - return { success: true, folder, restoredItems } - } catch (error) { - logger.error('Failed to restore workspace file folder', { error }) - if (getPostgresErrorCode(error) === '23505') { - return { - success: false, - error: 'A folder with this name already exists in this location', - errorCode: 'conflict', - } - } - return { success: false, error: toError(error).message, errorCode: 'internal' } + const restoredItems: WorkspaceFileArchiveResult = { + folders: result.restoredItems.folders, + files: result.restoredItems.files ?? 0, } + logger.info('Restored workspace file folder', { workspaceId, folderId, restoredItems }) + return { success: true, folder, restoredItems } } diff --git a/apps/sim/lib/workspaces/naming.ts b/apps/sim/lib/workspaces/naming.ts index 5bb9f80a789..f0da8319a0b 100644 --- a/apps/sim/lib/workspaces/naming.ts +++ b/apps/sim/lib/workspaces/naming.ts @@ -95,7 +95,7 @@ export function generateWorkspaceName(): string { async function fetchWorkspaceFolders(workspaceId: string): Promise { const { folders } = await requestJson(listFoldersContract, { - query: { workspaceId }, + query: { workspaceId, resourceType: 'workflow' }, }) return folders } diff --git a/apps/sim/stores/folders/types.ts b/apps/sim/stores/folders/types.ts index b51f14679e7..18adccf223b 100644 --- a/apps/sim/stores/folders/types.ts +++ b/apps/sim/stores/folders/types.ts @@ -1,19 +1,20 @@ -export interface WorkflowFolder { +import type { FolderResourceType } from '@/lib/api/contracts/folders' + +export interface Folder { id: string + resourceType: FolderResourceType name: string userId: string workspaceId: string parentId: string | null - color: string - isExpanded: boolean locked: boolean sortOrder: number createdAt: Date updatedAt: Date - archivedAt?: Date | null + deletedAt?: Date | null } -export interface FolderTreeNode extends WorkflowFolder { +export interface FolderTreeNode extends Folder { children: FolderTreeNode[] level: number } diff --git a/apps/sim/vitest.setup.ts b/apps/sim/vitest.setup.ts index b9a9ae7d14a..3d084251620 100644 --- a/apps/sim/vitest.setup.ts +++ b/apps/sim/vitest.setup.ts @@ -5,6 +5,7 @@ import { hybridAuthMock, loggerMock, requestUtilsMock, + resourceLockMock, schemaMock, setupGlobalFetchMock, setupGlobalStorageMocks, @@ -22,6 +23,7 @@ vi.mock('@sim/db/schema', () => schemaMock) vi.mock('drizzle-orm', () => drizzleOrmMock) vi.mock('@sim/logger', () => loggerMock) vi.mock('@sim/platform-authz/workflow', () => workflowAuthzMock) +vi.mock('@sim/platform-authz/resource-lock', () => resourceLockMock) vi.mock('@/lib/auth', () => authMock) vi.mock('@/lib/auth/hybrid', () => hybridAuthMock) vi.mock('@/lib/core/utils/request', () => requestUtilsMock) diff --git a/packages/db/migrations/0260_smart_wrecker.sql b/packages/db/migrations/0260_smart_wrecker.sql new file mode 100644 index 00000000000..39e624dfa10 --- /dev/null +++ b/packages/db/migrations/0260_smart_wrecker.sql @@ -0,0 +1,150 @@ +CREATE TYPE "public"."folder_resource_type" AS ENUM('workflow', 'file', 'knowledge_base', 'table');--> statement-breakpoint +CREATE TABLE "folder" ( + "id" text PRIMARY KEY NOT NULL, + "resource_type" "folder_resource_type" NOT NULL, + "name" text NOT NULL, + "user_id" text NOT NULL, + "workspace_id" text NOT NULL, + "parent_id" text, + "locked" boolean DEFAULT false NOT NULL, + "sort_order" integer DEFAULT 0 NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL, + "deleted_at" timestamp +); +--> statement-breakpoint +CREATE TABLE "pinned_item" ( + "id" text PRIMARY KEY NOT NULL, + "user_id" text NOT NULL, + "workspace_id" text NOT NULL, + "resource_type" text NOT NULL, + "resource_id" text NOT NULL, + "pinned_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +-- Drops the FK's old target only, no replacement added here — this strictly loosens +-- the column (removes a check, adds none), so it cannot reject any write that used to +-- succeed. Adding a `folder`-targeted FK in this same migration would reject a +-- still-running old-code pod (blue/green cutover) writing a workflow_folder-only id; +-- that FK is deferred to a follow-up migration once old code is fully drained +-- (contract-pending marker on workflow.folderId in schema.ts). App-layer validation +-- already independently enforces the invariant on both old and new code paths. +-- migration-safe: strictly loosens the column, no cross-deploy write can be rejected by removing this constraint; see contract-pending marker on workflow.folderId in schema.ts +ALTER TABLE "workflow" DROP CONSTRAINT "workflow_folder_id_workflow_folder_id_fk"; +--> statement-breakpoint +-- Same reasoning as the workflow.folder_id drop above. +-- migration-safe: strictly loosens the column, no cross-deploy write can be rejected by removing this constraint; see contract-pending marker on workspaceFiles.folderId in schema.ts +ALTER TABLE "workspace_files" DROP CONSTRAINT "workspace_files_folder_id_workspace_file_folders_id_fk"; +--> statement-breakpoint +ALTER TABLE "knowledge_base" ADD COLUMN "folder_id" text;--> statement-breakpoint +ALTER TABLE "knowledge_base" ADD COLUMN "locked" boolean DEFAULT false NOT NULL;--> statement-breakpoint +ALTER TABLE "user_table_definitions" ADD COLUMN "folder_id" text;--> statement-breakpoint +ALTER TABLE "user_table_definitions" ADD COLUMN "locked" boolean DEFAULT false NOT NULL;--> statement-breakpoint +ALTER TABLE "workspace_files" ADD COLUMN "locked" boolean DEFAULT false NOT NULL;--> statement-breakpoint +ALTER TABLE "folder" ADD CONSTRAINT "folder_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "folder" ADD CONSTRAINT "folder_workspace_id_workspace_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspace"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "folder" ADD CONSTRAINT "folder_parent_id_folder_id_fk" FOREIGN KEY ("parent_id") REFERENCES "public"."folder"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "pinned_item" ADD CONSTRAINT "pinned_item_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "pinned_item" ADD CONSTRAINT "pinned_item_workspace_id_workspace_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspace"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "folder_user_idx" ON "folder" USING btree ("user_id");--> statement-breakpoint +CREATE INDEX "folder_resource_type_idx" ON "folder" USING btree ("resource_type");--> statement-breakpoint +CREATE INDEX "folder_workspace_resource_parent_idx" ON "folder" USING btree ("workspace_id","resource_type","parent_id");--> statement-breakpoint +CREATE INDEX "folder_parent_sort_idx" ON "folder" USING btree ("parent_id","sort_order");--> statement-breakpoint +CREATE INDEX "folder_deleted_at_idx" ON "folder" USING btree ("deleted_at");--> statement-breakpoint +CREATE INDEX "folder_workspace_deleted_partial_idx" ON "folder" USING btree ("workspace_id","deleted_at") WHERE "folder"."deleted_at" IS NOT NULL;--> statement-breakpoint +CREATE UNIQUE INDEX "folder_workspace_resource_parent_name_active_unique" ON "folder" USING btree ("workspace_id","resource_type",coalesce("parent_id", ''),"name") WHERE "folder"."deleted_at" IS NULL;--> statement-breakpoint +CREATE INDEX "pinned_item_user_workspace_idx" ON "pinned_item" USING btree ("user_id","workspace_id");--> statement-breakpoint +CREATE INDEX "pinned_item_resource_idx" ON "pinned_item" USING btree ("resource_type","resource_id");--> statement-breakpoint +CREATE UNIQUE INDEX "pinned_item_user_resource_unique" ON "pinned_item" USING btree ("user_id","resource_type","resource_id");--> statement-breakpoint +-- Defense-in-depth: reject a folder row whose parent belongs to a different resourceType. +-- Mirrored at the application layer by assertFolderParentValid() in apps/sim/lib/folders/orchestration.ts. +CREATE FUNCTION "folder_parent_resource_type_match"() RETURNS trigger AS $$ +DECLARE + parent_resource_type "folder_resource_type"; + parent_workspace_id text; +BEGIN + IF NEW.parent_id IS NOT NULL THEN + SELECT resource_type, workspace_id INTO parent_resource_type, parent_workspace_id FROM "folder" WHERE id = NEW.parent_id; + IF parent_resource_type IS NOT NULL AND parent_resource_type <> NEW.resource_type THEN + RAISE EXCEPTION 'folder.parent_id % has resource_type % but row has resource_type %', + NEW.parent_id, parent_resource_type, NEW.resource_type; + END IF; + IF parent_workspace_id IS NOT NULL AND parent_workspace_id <> NEW.workspace_id THEN + RAISE EXCEPTION 'folder.parent_id % has workspace_id % but row has workspace_id %', + NEW.parent_id, parent_workspace_id, NEW.workspace_id; + END IF; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; +--> statement-breakpoint +CREATE TRIGGER "folder_parent_resource_type_match" +BEFORE INSERT OR UPDATE ON "folder" +FOR EACH ROW EXECUTE FUNCTION "folder_parent_resource_type_match"(); +--> statement-breakpoint +-- Backfill: copy existing workflow folders into the generic table, preserving id verbatim. +-- Source table also has color/is_expanded columns, but the generic `folder` table dropped +-- them (no UI consumer for color; is_expanded's real state lives client-side in the +-- folders Zustand store, never read from the DB), so they are intentionally not carried over. +-- +-- `workflow_folder` has no uniqueness constraint on (workspace_id, parent_id, name) today, +-- but the new `folder` table does (folder_workspace_resource_parent_name_active_unique, +-- scoped to active rows). Production has genuine active duplicates (verified against +-- prod: 33 groups, mostly default "Folder 1"/"Folder 2" names) that would otherwise abort +-- this INSERT with a unique-violation. Deduplicate active rows at backfill time by +-- appending " (N)" per collision, ordered by created_at then id for determinism -- +-- matching the "New folder (N)" convention this app's own create-folder dedup already +-- uses, so a renamed row reads as expected in the UI. Archived rows are exempt from the +-- constraint (WHERE deleted_at IS NULL) and are partitioned separately so they're never +-- renamed. +INSERT INTO "folder" (id, resource_type, name, user_id, workspace_id, parent_id, locked, sort_order, created_at, updated_at, deleted_at) +SELECT + id, 'workflow', + CASE WHEN archived_at IS NULL AND rn > 1 THEN name || ' (' || rn || ')' ELSE name END, + user_id, workspace_id, parent_id, locked, sort_order, created_at, updated_at, archived_at +FROM ( + SELECT *, ROW_NUMBER() OVER ( + PARTITION BY workspace_id, coalesce(parent_id, ''), name, (archived_at IS NULL) + ORDER BY created_at, id + ) AS rn + FROM "workflow_folder" +) "ranked_workflow_folder"; +--> statement-breakpoint +-- Backfill: copy existing file folders into the generic table, preserving id verbatim. +-- Source table has no locked column, so use the same default as new rows. No active +-- duplicates exist in production today (verified), but the same defensive dedup as the +-- workflow backfill above is applied in case one is created before this migration runs. +INSERT INTO "folder" (id, resource_type, name, user_id, workspace_id, parent_id, locked, sort_order, created_at, updated_at, deleted_at) +SELECT + id, 'file', + CASE WHEN deleted_at IS NULL AND rn > 1 THEN name || ' (' || rn || ')' ELSE name END, + user_id, workspace_id, parent_id, false, sort_order, created_at, updated_at, deleted_at +FROM ( + SELECT *, ROW_NUMBER() OVER ( + PARTITION BY workspace_id, coalesce(parent_id, ''), name, (deleted_at IS NULL) + ORDER BY created_at, id + ) AS rn + FROM "workspace_file_folders" +) "ranked_workspace_file_folders"; +--> statement-breakpoint +-- knowledge_base.folder_id / user_table_definitions.folder_id are brand-new columns added +-- earlier in this same migration (all rows NULL) -- no old or new code reads/writes them +-- yet, so adding their FK here (unlike workflow/workspace_files above) has zero cross-deploy +-- write-compatibility risk. NOT VALID + an immediate VALIDATE still avoids the full-table +-- lock a plain ADD CONSTRAINT would take (validating an all-NULL column is instant either +-- way, but this keeps the pattern uniform and matches this repo's established precedent, +-- e.g. migrations/0243_kb_workspace_cascade.sql). +ALTER TABLE "knowledge_base" ADD CONSTRAINT "knowledge_base_folder_id_folder_id_fk" FOREIGN KEY ("folder_id") REFERENCES "public"."folder"("id") ON DELETE set null ON UPDATE no action NOT VALID;--> statement-breakpoint +ALTER TABLE "knowledge_base" VALIDATE CONSTRAINT "knowledge_base_folder_id_folder_id_fk";--> statement-breakpoint +ALTER TABLE "user_table_definitions" ADD CONSTRAINT "user_table_definitions_folder_id_folder_id_fk" FOREIGN KEY ("folder_id") REFERENCES "public"."folder"("id") ON DELETE set null ON UPDATE no action NOT VALID;--> statement-breakpoint +ALTER TABLE "user_table_definitions" VALIDATE CONSTRAINT "user_table_definitions_folder_id_folder_id_fk";--> statement-breakpoint +-- knowledge_base/user_table_definitions are existing tables: build these indexes +-- CONCURRENTLY so the build never write-locks the relation (runner convention -- plain +-- CREATE INDEX takes ACCESS EXCLUSIVE; see packages/db/scripts/migrate.ts). This must be +-- the last statement group in the file -- CONCURRENTLY cannot run inside the migration +-- transaction, so everything after the COMMIT below runs outside it. +COMMIT;--> statement-breakpoint +SET lock_timeout = 0;--> statement-breakpoint +CREATE INDEX CONCURRENTLY IF NOT EXISTS "kb_folder_id_idx" ON "knowledge_base" USING btree ("folder_id");--> statement-breakpoint +CREATE INDEX CONCURRENTLY IF NOT EXISTS "user_table_def_folder_id_idx" ON "user_table_definitions" USING btree ("folder_id");--> statement-breakpoint +SET lock_timeout = '5s'; diff --git a/packages/db/migrations/meta/0260_snapshot.json b/packages/db/migrations/meta/0260_snapshot.json new file mode 100644 index 00000000000..131e22976c3 --- /dev/null +++ b/packages/db/migrations/meta/0260_snapshot.json @@ -0,0 +1,17231 @@ +{ + "id": "3ad64a20-abd3-4bf5-9de3-b2d157281028", + "prevId": "45c74f69-0c69-4c99-9f41-eb2d22843935", + "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": "'[]'" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "identifier_idx": { + "name": "identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"chat\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_archived_at_partial_idx": { + "name": "chat_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"chat\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_chat_on_workflow_id_archived_at": { + "name": "idx_chat_on_workflow_id_archived_at", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_workflow_id_workflow_id_fk": { + "name": "chat_workflow_id_workflow_id_fk", + "tableFrom": "chat", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chat_user_id_user_id_fk": { + "name": "chat_user_id_user_id_fk", + "tableFrom": "chat", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_async_tool_calls": { + "name": "copilot_async_tool_calls", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "checkpoint_id": { + "name": "checkpoint_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "tool_call_id": { + "name": "tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "args": { + "name": "args", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "status": { + "name": "status", + "type": "copilot_async_tool_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "claimed_by": { + "name": "claimed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_async_tool_calls_run_id_idx": { + "name": "copilot_async_tool_calls_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_checkpoint_id_idx": { + "name": "copilot_async_tool_calls_checkpoint_id_idx", + "columns": [ + { + "expression": "checkpoint_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_tool_call_id_idx": { + "name": "copilot_async_tool_calls_tool_call_id_idx", + "columns": [ + { + "expression": "tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_status_idx": { + "name": "copilot_async_tool_calls_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_run_status_idx": { + "name": "copilot_async_tool_calls_run_status_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_tool_call_id_unique": { + "name": "copilot_async_tool_calls_tool_call_id_unique", + "columns": [ + { + "expression": "tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_async_tool_calls_run_id_copilot_runs_id_fk": { + "name": "copilot_async_tool_calls_run_id_copilot_runs_id_fk", + "tableFrom": "copilot_async_tool_calls", + "tableTo": "copilot_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_async_tool_calls_checkpoint_id_copilot_run_checkpoints_id_fk": { + "name": "copilot_async_tool_calls_checkpoint_id_copilot_run_checkpoints_id_fk", + "tableFrom": "copilot_async_tool_calls", + "tableTo": "copilot_run_checkpoints", + "columnsFrom": ["checkpoint_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_chats": { + "name": "copilot_chats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "chat_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'copilot'" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'claude-3-7-sonnet-latest'" + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "preview_yaml": { + "name": "preview_yaml", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "plan_artifact": { + "name": "plan_artifact", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "resources": { + "name": "resources", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "pinned": { + "name": "pinned", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_chats_user_id_idx": { + "name": "copilot_chats_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_workflow_id_idx": { + "name": "copilot_chats_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_user_workflow_idx": { + "name": "copilot_chats_user_workflow_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_user_workspace_idx": { + "name": "copilot_chats_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_created_at_idx": { + "name": "copilot_chats_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_updated_at_idx": { + "name": "copilot_chats_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_workspace_created_at_id_idx": { + "name": "copilot_chats_workspace_created_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"created_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_chats_user_id_user_id_fk": { + "name": "copilot_chats_user_id_user_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_chats_workflow_id_workflow_id_fk": { + "name": "copilot_chats_workflow_id_workflow_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_chats_workspace_id_workspace_id_fk": { + "name": "copilot_chats_workspace_id_workspace_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_feedback": { + "name": "copilot_feedback", + "schema": "", + "columns": { + "feedback_id": { + "name": "feedback_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_query": { + "name": "user_query", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_response": { + "name": "agent_response", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_positive": { + "name": "is_positive", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "feedback": { + "name": "feedback", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_yaml": { + "name": "workflow_yaml", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_feedback_user_id_idx": { + "name": "copilot_feedback_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_chat_id_idx": { + "name": "copilot_feedback_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_user_chat_idx": { + "name": "copilot_feedback_user_chat_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_is_positive_idx": { + "name": "copilot_feedback_is_positive_idx", + "columns": [ + { + "expression": "is_positive", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_created_at_idx": { + "name": "copilot_feedback_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_feedback_user_id_user_id_fk": { + "name": "copilot_feedback_user_id_user_id_fk", + "tableFrom": "copilot_feedback", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_feedback_chat_id_copilot_chats_id_fk": { + "name": "copilot_feedback_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_feedback", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_messages": { + "name": "copilot_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "stream_id": { + "name": "stream_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parent_message_id": { + "name": "parent_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tokens_in": { + "name": "tokens_in", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "tokens_out": { + "name": "tokens_out", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_messages_chat_message_unique": { + "name": "copilot_messages_chat_message_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_chat_created_at_idx": { + "name": "copilot_messages_chat_created_at_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_chat_seq_idx": { + "name": "copilot_messages_chat_seq_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "seq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_chat_stream_idx": { + "name": "copilot_messages_chat_stream_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "stream_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"stream_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_messages_chat_id_copilot_chats_id_fk": { + "name": "copilot_messages_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_messages", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_run_checkpoints": { + "name": "copilot_run_checkpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "pending_tool_call_id": { + "name": "pending_tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conversation_snapshot": { + "name": "conversation_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "agent_state": { + "name": "agent_state", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "provider_request": { + "name": "provider_request", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_run_checkpoints_run_id_idx": { + "name": "copilot_run_checkpoints_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_run_checkpoints_pending_tool_call_id_idx": { + "name": "copilot_run_checkpoints_pending_tool_call_id_idx", + "columns": [ + { + "expression": "pending_tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_run_checkpoints_run_pending_tool_unique": { + "name": "copilot_run_checkpoints_run_pending_tool_unique", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pending_tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_run_checkpoints_run_id_copilot_runs_id_fk": { + "name": "copilot_run_checkpoints_run_id_copilot_runs_id_fk", + "tableFrom": "copilot_run_checkpoints", + "tableTo": "copilot_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_runs": { + "name": "copilot_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_run_id": { + "name": "parent_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stream_id": { + "name": "stream_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent": { + "name": "agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "copilot_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "request_context": { + "name": "request_context", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "copilot_runs_execution_id_idx": { + "name": "copilot_runs_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_parent_run_id_idx": { + "name": "copilot_runs_parent_run_id_idx", + "columns": [ + { + "expression": "parent_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_chat_id_idx": { + "name": "copilot_runs_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_user_id_idx": { + "name": "copilot_runs_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_workflow_id_idx": { + "name": "copilot_runs_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_workspace_id_idx": { + "name": "copilot_runs_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_status_idx": { + "name": "copilot_runs_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_chat_execution_idx": { + "name": "copilot_runs_chat_execution_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_execution_started_at_idx": { + "name": "copilot_runs_execution_started_at_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_workspace_completed_at_id_idx": { + "name": "copilot_runs_workspace_completed_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"completed_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_stream_id_unique": { + "name": "copilot_runs_stream_id_unique", + "columns": [ + { + "expression": "stream_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_runs_chat_id_copilot_chats_id_fk": { + "name": "copilot_runs_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_user_id_user_id_fk": { + "name": "copilot_runs_user_id_user_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_workflow_id_workflow_id_fk": { + "name": "copilot_runs_workflow_id_workflow_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_workspace_id_workspace_id_fk": { + "name": "copilot_runs_workspace_id_workspace_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_workflow_read_hashes": { + "name": "copilot_workflow_read_hashes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_workflow_read_hashes_chat_id_idx": { + "name": "copilot_workflow_read_hashes_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_workflow_read_hashes_workflow_id_idx": { + "name": "copilot_workflow_read_hashes_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_workflow_read_hashes_chat_workflow_unique": { + "name": "copilot_workflow_read_hashes_chat_workflow_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_workflow_read_hashes_chat_id_copilot_chats_id_fk": { + "name": "copilot_workflow_read_hashes_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_workflow_read_hashes", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_workflow_read_hashes_workflow_id_workflow_id_fk": { + "name": "copilot_workflow_read_hashes_workflow_id_workflow_id_fk", + "tableFrom": "copilot_workflow_read_hashes", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credential": { + "name": "credential", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "credential_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env_key": { + "name": "env_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env_owner_user_id": { + "name": "env_owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_service_account_key": { + "name": "encrypted_service_account_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_workspace_id_idx": { + "name": "credential_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_type_idx": { + "name": "credential_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_provider_id_idx": { + "name": "credential_provider_id_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_account_id_idx": { + "name": "credential_account_id_idx", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_env_owner_user_id_idx": { + "name": "credential_env_owner_user_id_idx", + "columns": [ + { + "expression": "env_owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_account_unique": { + "name": "credential_workspace_account_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "account_id IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_env_unique": { + "name": "credential_workspace_env_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "type = 'env_workspace'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_personal_env_unique": { + "name": "credential_workspace_personal_env_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "type = 'env_personal'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_workspace_id_workspace_id_fk": { + "name": "credential_workspace_id_workspace_id_fk", + "tableFrom": "credential", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_account_id_account_id_fk": { + "name": "credential_account_id_account_id_fk", + "tableFrom": "credential", + "tableTo": "account", + "columnsFrom": ["account_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_env_owner_user_id_user_id_fk": { + "name": "credential_env_owner_user_id_user_id_fk", + "tableFrom": "credential", + "tableTo": "user", + "columnsFrom": ["env_owner_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_created_by_user_id_fk": { + "name": "credential_created_by_user_id_fk", + "tableFrom": "credential", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "credential_oauth_source_check": { + "name": "credential_oauth_source_check", + "value": "(type <> 'oauth') OR (account_id IS NOT NULL AND provider_id IS NOT NULL)" + }, + "credential_workspace_env_source_check": { + "name": "credential_workspace_env_source_check", + "value": "(type <> 'env_workspace') OR (env_key IS NOT NULL AND env_owner_user_id IS NULL)" + }, + "credential_personal_env_source_check": { + "name": "credential_personal_env_source_check", + "value": "(type <> 'env_personal') OR (env_key IS NOT NULL AND env_owner_user_id IS NOT NULL)" + } + }, + "isRLSEnabled": false + }, + "public.credential_member": { + "name": "credential_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "credential_member_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "status": { + "name": "status", + "type": "credential_member_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_member_user_id_idx": { + "name": "credential_member_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_role_idx": { + "name": "credential_member_role_idx", + "columns": [ + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_status_idx": { + "name": "credential_member_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_unique": { + "name": "credential_member_unique", + "columns": [ + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_member_credential_id_credential_id_fk": { + "name": "credential_member_credential_id_credential_id_fk", + "tableFrom": "credential_member", + "tableTo": "credential", + "columnsFrom": ["credential_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_member_user_id_user_id_fk": { + "name": "credential_member_user_id_user_id_fk", + "tableFrom": "credential_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_member_invited_by_user_id_fk": { + "name": "credential_member_invited_by_user_id_fk", + "tableFrom": "credential_member", + "tableTo": "user", + "columnsFrom": ["invited_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.custom_block": { + "name": "custom_block", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "icon_url": { + "name": "icon_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "inputs": { + "name": "inputs", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "outputs": { + "name": "outputs", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "custom_block_organization_id_idx": { + "name": "custom_block_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_block_workflow_id_idx": { + "name": "custom_block_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_block_organization_type_unique": { + "name": "custom_block_organization_type_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "custom_block_organization_id_organization_id_fk": { + "name": "custom_block_organization_id_organization_id_fk", + "tableFrom": "custom_block", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "custom_block_workflow_id_workflow_id_fk": { + "name": "custom_block_workflow_id_workflow_id_fk", + "tableFrom": "custom_block", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "custom_block_created_by_user_id_fk": { + "name": "custom_block_created_by_user_id_fk", + "tableFrom": "custom_block", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.custom_tools": { + "name": "custom_tools", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schema": { + "name": "schema", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "custom_tools_workspace_id_idx": { + "name": "custom_tools_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_tools_workspace_title_unique": { + "name": "custom_tools_workspace_title_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "title", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "custom_tools_workspace_id_workspace_id_fk": { + "name": "custom_tools_workspace_id_workspace_id_fk", + "tableFrom": "custom_tools", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "custom_tools_user_id_user_id_fk": { + "name": "custom_tools_user_id_user_id_fk", + "tableFrom": "custom_tools", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.data_drain_runs": { + "name": "data_drain_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "drain_id": { + "name": "drain_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "data_drain_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "trigger": { + "name": "trigger", + "type": "data_drain_run_trigger", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "rows_exported": { + "name": "rows_exported", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "bytes_written": { + "name": "bytes_written", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cursor_before": { + "name": "cursor_before", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cursor_after": { + "name": "cursor_after", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "locators": { + "name": "locators", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + } + }, + "indexes": { + "data_drain_runs_drain_started_idx": { + "name": "data_drain_runs_drain_started_idx", + "columns": [ + { + "expression": "drain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "data_drain_runs_drain_id_data_drains_id_fk": { + "name": "data_drain_runs_drain_id_data_drains_id_fk", + "tableFrom": "data_drain_runs", + "tableTo": "data_drains", + "columnsFrom": ["drain_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.data_drains": { + "name": "data_drains", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "data_drain_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "destination_type": { + "name": "destination_type", + "type": "data_drain_destination", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "destination_config": { + "name": "destination_config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "destination_credentials": { + "name": "destination_credentials", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schedule_cadence": { + "name": "schedule_cadence", + "type": "data_drain_cadence", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "cursor": { + "name": "cursor", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_success_at": { + "name": "last_success_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "data_drains_org_idx": { + "name": "data_drains_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "data_drains_due_idx": { + "name": "data_drains_due_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "data_drains_org_name_unique": { + "name": "data_drains_org_name_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "data_drains_organization_id_organization_id_fk": { + "name": "data_drains_organization_id_organization_id_fk", + "tableFrom": "data_drains", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "data_drains_created_by_user_id_fk": { + "name": "data_drains_created_by_user_id_fk", + "tableFrom": "data_drains", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.docs_embeddings": { + "name": "docs_embeddings", + "schema": "", + "columns": { + "chunk_id": { + "name": "chunk_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chunk_text": { + "name": "chunk_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_document": { + "name": "source_document", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_link": { + "name": "source_link", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "header_text": { + "name": "header_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "header_level": { + "name": "header_level", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": true + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "chunk_text_tsv": { + "name": "chunk_text_tsv", + "type": "tsvector", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "to_tsvector('english', \"docs_embeddings\".\"chunk_text\")", + "type": "stored" + } + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "docs_emb_source_document_idx": { + "name": "docs_emb_source_document_idx", + "columns": [ + { + "expression": "source_document", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_header_level_idx": { + "name": "docs_emb_header_level_idx", + "columns": [ + { + "expression": "header_level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_source_header_idx": { + "name": "docs_emb_source_header_idx", + "columns": [ + { + "expression": "source_document", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "header_level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_model_idx": { + "name": "docs_emb_model_idx", + "columns": [ + { + "expression": "embedding_model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_created_at_idx": { + "name": "docs_emb_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_embedding_vector_hnsw_idx": { + "name": "docs_embedding_vector_hnsw_idx", + "columns": [ + { + "expression": "embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "docs_emb_metadata_gin_idx": { + "name": "docs_emb_metadata_gin_idx", + "columns": [ + { + "expression": "metadata", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "docs_emb_chunk_text_fts_idx": { + "name": "docs_emb_chunk_text_fts_idx", + "columns": [ + { + "expression": "chunk_text_tsv", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "docs_embedding_not_null_check": { + "name": "docs_embedding_not_null_check", + "value": "\"embedding\" IS NOT NULL" + }, + "docs_header_level_check": { + "name": "docs_header_level_check", + "value": "\"header_level\" >= 1 AND \"header_level\" <= 6" + } + }, + "isRLSEnabled": false + }, + "public.document": { + "name": "document", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_url": { + "name": "file_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "file_size": { + "name": "file_size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chunk_count": { + "name": "chunk_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "character_count": { + "name": "character_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "processing_status": { + "name": "processing_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "processing_started_at": { + "name": "processing_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "processing_completed_at": { + "name": "processing_completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "processing_error": { + "name": "processing_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "user_excluded": { + "name": "user_excluded", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "tag1": { + "name": "tag1", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag2": { + "name": "tag2", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag3": { + "name": "tag3", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag4": { + "name": "tag4", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag5": { + "name": "tag5", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag6": { + "name": "tag6", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag7": { + "name": "tag7", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "number1": { + "name": "number1", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number2": { + "name": "number2", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number3": { + "name": "number3", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number4": { + "name": "number4", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number5": { + "name": "number5", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "date1": { + "name": "date1", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "date2": { + "name": "date2", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "boolean1": { + "name": "boolean1", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean2": { + "name": "boolean2", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean3": { + "name": "boolean3", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "connector_id": { + "name": "connector_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_url": { + "name": "source_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "doc_kb_id_idx": { + "name": "doc_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_filename_idx": { + "name": "doc_filename_idx", + "columns": [ + { + "expression": "filename", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_processing_status_idx": { + "name": "doc_processing_status_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "processing_status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_connector_external_id_idx": { + "name": "doc_connector_external_id_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"document\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_connector_id_idx": { + "name": "doc_connector_id_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_storage_key_idx": { + "name": "doc_storage_key_idx", + "columns": [ + { + "expression": "storage_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"storage_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_archived_at_partial_idx": { + "name": "doc_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_deleted_at_partial_idx": { + "name": "doc_deleted_at_partial_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag1_idx": { + "name": "doc_tag1_idx", + "columns": [ + { + "expression": "tag1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag2_idx": { + "name": "doc_tag2_idx", + "columns": [ + { + "expression": "tag2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag3_idx": { + "name": "doc_tag3_idx", + "columns": [ + { + "expression": "tag3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag4_idx": { + "name": "doc_tag4_idx", + "columns": [ + { + "expression": "tag4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag5_idx": { + "name": "doc_tag5_idx", + "columns": [ + { + "expression": "tag5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag6_idx": { + "name": "doc_tag6_idx", + "columns": [ + { + "expression": "tag6", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag7_idx": { + "name": "doc_tag7_idx", + "columns": [ + { + "expression": "tag7", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number1_idx": { + "name": "doc_number1_idx", + "columns": [ + { + "expression": "number1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number2_idx": { + "name": "doc_number2_idx", + "columns": [ + { + "expression": "number2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number3_idx": { + "name": "doc_number3_idx", + "columns": [ + { + "expression": "number3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number4_idx": { + "name": "doc_number4_idx", + "columns": [ + { + "expression": "number4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number5_idx": { + "name": "doc_number5_idx", + "columns": [ + { + "expression": "number5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_date1_idx": { + "name": "doc_date1_idx", + "columns": [ + { + "expression": "date1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_date2_idx": { + "name": "doc_date2_idx", + "columns": [ + { + "expression": "date2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean1_idx": { + "name": "doc_boolean1_idx", + "columns": [ + { + "expression": "boolean1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean2_idx": { + "name": "doc_boolean2_idx", + "columns": [ + { + "expression": "boolean2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean3_idx": { + "name": "doc_boolean3_idx", + "columns": [ + { + "expression": "boolean3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "document_knowledge_base_id_knowledge_base_id_fk": { + "name": "document_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "document", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "document_connector_id_knowledge_connector_id_fk": { + "name": "document_connector_id_knowledge_connector_id_fk", + "tableFrom": "document", + "tableTo": "knowledge_connector", + "columnsFrom": ["connector_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "document_uploaded_by_user_id_fk": { + "name": "document_uploaded_by_user_id_fk", + "tableFrom": "document", + "tableTo": "user", + "columnsFrom": ["uploaded_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.embedding": { + "name": "embedding", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chunk_index": { + "name": "chunk_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "chunk_hash": { + "name": "chunk_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_length": { + "name": "content_length", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": false + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "start_offset": { + "name": "start_offset", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "end_offset": { + "name": "end_offset", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "tag1": { + "name": "tag1", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag2": { + "name": "tag2", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag3": { + "name": "tag3", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag4": { + "name": "tag4", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag5": { + "name": "tag5", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag6": { + "name": "tag6", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag7": { + "name": "tag7", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "number1": { + "name": "number1", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number2": { + "name": "number2", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number3": { + "name": "number3", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number4": { + "name": "number4", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number5": { + "name": "number5", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "date1": { + "name": "date1", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "date2": { + "name": "date2", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "boolean1": { + "name": "boolean1", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean2": { + "name": "boolean2", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean3": { + "name": "boolean3", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "content_tsv": { + "name": "content_tsv", + "type": "tsvector", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "to_tsvector('english', \"embedding\".\"content\")", + "type": "stored" + } + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "emb_kb_id_idx": { + "name": "emb_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_id_idx": { + "name": "emb_doc_id_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_chunk_idx": { + "name": "emb_doc_chunk_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chunk_index", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_model_idx": { + "name": "emb_kb_model_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "embedding_model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_enabled_idx": { + "name": "emb_kb_enabled_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_enabled_idx": { + "name": "emb_doc_enabled_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "embedding_vector_hnsw_idx": { + "name": "embedding_vector_hnsw_idx", + "columns": [ + { + "expression": "embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "emb_tag1_idx": { + "name": "emb_tag1_idx", + "columns": [ + { + "expression": "tag1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag2_idx": { + "name": "emb_tag2_idx", + "columns": [ + { + "expression": "tag2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag3_idx": { + "name": "emb_tag3_idx", + "columns": [ + { + "expression": "tag3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag4_idx": { + "name": "emb_tag4_idx", + "columns": [ + { + "expression": "tag4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag5_idx": { + "name": "emb_tag5_idx", + "columns": [ + { + "expression": "tag5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag6_idx": { + "name": "emb_tag6_idx", + "columns": [ + { + "expression": "tag6", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag7_idx": { + "name": "emb_tag7_idx", + "columns": [ + { + "expression": "tag7", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number1_idx": { + "name": "emb_number1_idx", + "columns": [ + { + "expression": "number1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number2_idx": { + "name": "emb_number2_idx", + "columns": [ + { + "expression": "number2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number3_idx": { + "name": "emb_number3_idx", + "columns": [ + { + "expression": "number3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number4_idx": { + "name": "emb_number4_idx", + "columns": [ + { + "expression": "number4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number5_idx": { + "name": "emb_number5_idx", + "columns": [ + { + "expression": "number5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_date1_idx": { + "name": "emb_date1_idx", + "columns": [ + { + "expression": "date1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_date2_idx": { + "name": "emb_date2_idx", + "columns": [ + { + "expression": "date2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_boolean1_idx": { + "name": "emb_boolean1_idx", + "columns": [ + { + "expression": "boolean1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_boolean2_idx": { + "name": "emb_boolean2_idx", + "columns": [ + { + "expression": "boolean2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_boolean3_idx": { + "name": "emb_boolean3_idx", + "columns": [ + { + "expression": "boolean3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_content_fts_idx": { + "name": "emb_content_fts_idx", + "columns": [ + { + "expression": "content_tsv", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "embedding_knowledge_base_id_knowledge_base_id_fk": { + "name": "embedding_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "embedding", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "embedding_document_id_document_id_fk": { + "name": "embedding_document_id_document_id_fk", + "tableFrom": "embedding", + "tableTo": "document", + "columnsFrom": ["document_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "embedding_not_null_check": { + "name": "embedding_not_null_check", + "value": "\"embedding\" IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.environment": { + "name": "environment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "environment_user_id_user_id_fk": { + "name": "environment_user_id_user_id_fk", + "tableFrom": "environment", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "environment_user_id_unique": { + "name": "environment_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.execution_large_value_dependencies": { + "name": "execution_large_value_dependencies", + "schema": "", + "columns": { + "parent_key": { + "name": "parent_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_key": { + "name": "child_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "execution_large_value_dependencies_workspace_parent_key_idx": { + "name": "execution_large_value_dependencies_workspace_parent_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_value_dependencies_workspace_child_key_idx": { + "name": "execution_large_value_dependencies_workspace_child_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "child_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "execution_large_value_dependencies_workspace_id_workspace_id_fk": { + "name": "execution_large_value_dependencies_workspace_id_workspace_id_fk", + "tableFrom": "execution_large_value_dependencies", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "execution_large_value_dependencies_parent_key_child_key_pk": { + "name": "execution_large_value_dependencies_parent_key_child_key_pk", + "columns": ["parent_key", "child_key"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.execution_large_value_references": { + "name": "execution_large_value_references", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "execution_large_value_reference_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "execution_large_value_references_workspace_execution_source_idx": { + "name": "execution_large_value_references_workspace_execution_source_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "execution_large_value_references_workspace_id_workspace_id_fk": { + "name": "execution_large_value_references_workspace_id_workspace_id_fk", + "tableFrom": "execution_large_value_references", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "execution_large_value_references_workflow_id_workflow_id_fk": { + "name": "execution_large_value_references_workflow_id_workflow_id_fk", + "tableFrom": "execution_large_value_references", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "execution_large_value_references_key_execution_id_source_pk": { + "name": "execution_large_value_references_key_execution_id_source_pk", + "columns": ["key", "execution_id", "source"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.execution_large_values": { + "name": "execution_large_values", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_execution_id": { + "name": "owner_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "execution_large_values_owner_execution_id_idx": { + "name": "execution_large_values_owner_execution_id_idx", + "columns": [ + { + "expression": "owner_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_values_cleanup_idx": { + "name": "execution_large_values_cleanup_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"execution_large_values\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_values_tombstone_cleanup_idx": { + "name": "execution_large_values_tombstone_cleanup_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"execution_large_values\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "execution_large_values_workspace_id_workspace_id_fk": { + "name": "execution_large_values_workspace_id_workspace_id_fk", + "tableFrom": "execution_large_values", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "execution_large_values_workflow_id_workflow_id_fk": { + "name": "execution_large_values_workflow_id_workflow_id_fk", + "tableFrom": "execution_large_values", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.folder": { + "name": "folder", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "folder_resource_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "folder_user_idx": { + "name": "folder_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "folder_resource_type_idx": { + "name": "folder_resource_type_idx", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "folder_workspace_resource_parent_idx": { + "name": "folder_workspace_resource_parent_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "folder_parent_sort_idx": { + "name": "folder_parent_sort_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "folder_deleted_at_idx": { + "name": "folder_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "folder_workspace_deleted_partial_idx": { + "name": "folder_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"folder\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "folder_workspace_resource_parent_name_active_unique": { + "name": "folder_workspace_resource_parent_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"parent_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"folder\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "folder_user_id_user_id_fk": { + "name": "folder_user_id_user_id_fk", + "tableFrom": "folder", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "folder_workspace_id_workspace_id_fk": { + "name": "folder_workspace_id_workspace_id_fk", + "tableFrom": "folder", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "folder_parent_id_folder_id_fk": { + "name": "folder_parent_id_folder_id_fk", + "tableFrom": "folder", + "tableTo": "folder", + "columnsFrom": ["parent_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.idempotency_key": { + "name": "idempotency_key", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "result": { + "name": "result", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idempotency_key_created_at_idx": { + "name": "idempotency_key_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation": { + "name": "invitation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "invitation_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'organization'" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "membership_intent": { + "name": "membership_intent", + "type": "invitation_membership_intent", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'internal'" + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "invitation_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invitation_email_idx": { + "name": "invitation_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_organization_id_idx": { + "name": "invitation_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_status_idx": { + "name": "invitation_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_pending_email_org_unique": { + "name": "invitation_pending_email_org_unique", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"invitation\".\"status\" = 'pending' AND \"invitation\".\"organization_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitation_inviter_id_user_id_fk": { + "name": "invitation_inviter_id_user_id_fk", + "tableFrom": "invitation", + "tableTo": "user", + "columnsFrom": ["inviter_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_organization_id_organization_id_fk": { + "name": "invitation_organization_id_organization_id_fk", + "tableFrom": "invitation", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "invitation_token_unique": { + "name": "invitation_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation_workspace_grant": { + "name": "invitation_workspace_grant", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "invitation_id": { + "name": "invitation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission": { + "name": "permission", + "type": "permission_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invitation_workspace_grant_unique": { + "name": "invitation_workspace_grant_unique", + "columns": [ + { + "expression": "invitation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_workspace_grant_workspace_id_idx": { + "name": "invitation_workspace_grant_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitation_workspace_grant_invitation_id_invitation_id_fk": { + "name": "invitation_workspace_grant_invitation_id_invitation_id_fk", + "tableFrom": "invitation_workspace_grant", + "tableTo": "invitation", + "columnsFrom": ["invitation_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_workspace_grant_workspace_id_workspace_id_fk": { + "name": "invitation_workspace_grant_workspace_id_workspace_id_fk", + "tableFrom": "invitation_workspace_grant", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.job_execution_logs": { + "name": "job_execution_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "schedule_id": { + "name": "schedule_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_duration_ms": { + "name": "total_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "execution_data": { + "name": "execution_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "cost": { + "name": "cost", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "job_execution_logs_schedule_id_idx": { + "name": "job_execution_logs_schedule_id_idx", + "columns": [ + { + "expression": "schedule_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_workspace_started_at_idx": { + "name": "job_execution_logs_workspace_started_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_workspace_ended_at_id_idx": { + "name": "job_execution_logs_workspace_ended_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"ended_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_execution_id_unique": { + "name": "job_execution_logs_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_trigger_idx": { + "name": "job_execution_logs_trigger_idx", + "columns": [ + { + "expression": "trigger", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "job_execution_logs_schedule_id_workflow_schedule_id_fk": { + "name": "job_execution_logs_schedule_id_workflow_schedule_id_fk", + "tableFrom": "job_execution_logs", + "tableTo": "workflow_schedule", + "columnsFrom": ["schedule_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "job_execution_logs_workspace_id_workspace_id_fk": { + "name": "job_execution_logs_workspace_id_workspace_id_fk", + "tableFrom": "job_execution_logs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_base": { + "name": "knowledge_base", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "embedding_dimension": { + "name": "embedding_dimension", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1536 + }, + "chunking_config": { + "name": "chunking_config", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{\"maxSize\": 1024, \"minSize\": 1, \"overlap\": 200}'" + }, + "locked": { + "name": "locked", + "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": { + "kb_user_id_idx": { + "name": "kb_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_workspace_id_idx": { + "name": "kb_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_user_workspace_idx": { + "name": "kb_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_folder_id_idx": { + "name": "kb_folder_id_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_deleted_at_idx": { + "name": "kb_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_workspace_deleted_partial_idx": { + "name": "kb_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_base\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_workspace_name_active_unique": { + "name": "kb_workspace_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"knowledge_base\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_base_user_id_user_id_fk": { + "name": "knowledge_base_user_id_user_id_fk", + "tableFrom": "knowledge_base", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "knowledge_base_workspace_id_workspace_id_fk": { + "name": "knowledge_base_workspace_id_workspace_id_fk", + "tableFrom": "knowledge_base", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "knowledge_base_folder_id_folder_id_fk": { + "name": "knowledge_base_folder_id_folder_id_fk", + "tableFrom": "knowledge_base", + "tableTo": "folder", + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_base_tag_definitions": { + "name": "knowledge_base_tag_definitions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tag_slot": { + "name": "tag_slot", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "field_type": { + "name": "field_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "kb_tag_definitions_kb_slot_idx": { + "name": "kb_tag_definitions_kb_slot_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tag_slot", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_tag_definitions_kb_display_name_idx": { + "name": "kb_tag_definitions_kb_display_name_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "display_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_tag_definitions_kb_id_idx": { + "name": "kb_tag_definitions_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_base_tag_definitions_knowledge_base_id_knowledge_base_id_fk": { + "name": "knowledge_base_tag_definitions_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "knowledge_base_tag_definitions", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_connector": { + "name": "knowledge_connector", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connector_type": { + "name": "connector_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_api_key": { + "name": "encrypted_api_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_config": { + "name": "source_config", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "sync_mode": { + "name": "sync_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'full'" + }, + "sync_interval_minutes": { + "name": "sync_interval_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1440 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "last_sync_at": { + "name": "last_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_sync_error": { + "name": "last_sync_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_sync_doc_count": { + "name": "last_sync_doc_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "next_sync_at": { + "name": "next_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "consecutive_failures": { + "name": "consecutive_failures", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "kc_knowledge_base_id_idx": { + "name": "kc_knowledge_base_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_status_next_sync_idx": { + "name": "kc_status_next_sync_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_sync_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_archived_at_partial_idx": { + "name": "kc_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_connector\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_deleted_at_partial_idx": { + "name": "kc_deleted_at_partial_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_connector\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_connector_knowledge_base_id_knowledge_base_id_fk": { + "name": "knowledge_connector_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "knowledge_connector", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_connector_sync_log": { + "name": "knowledge_connector_sync_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connector_id": { + "name": "connector_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "docs_added": { + "name": "docs_added", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_updated": { + "name": "docs_updated", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_deleted": { + "name": "docs_deleted", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_unchanged": { + "name": "docs_unchanged", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_failed": { + "name": "docs_failed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "kcsl_connector_id_idx": { + "name": "kcsl_connector_id_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_connector_sync_log_connector_id_knowledge_connector_id_fk": { + "name": "knowledge_connector_sync_log_connector_id_knowledge_connector_id_fk", + "tableFrom": "knowledge_connector_sync_log", + "tableTo": "knowledge_connector", + "columnsFrom": ["connector_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_server_oauth": { + "name": "mcp_server_oauth", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "mcp_server_id": { + "name": "mcp_server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_information": { + "name": "client_information", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tokens": { + "name": "tokens", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "code_verifier": { + "name": "code_verifier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state_created_at": { + "name": "state_created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_refreshed_at": { + "name": "last_refreshed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_server_oauth_server_unique": { + "name": "mcp_server_oauth_server_unique", + "columns": [ + { + "expression": "mcp_server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_server_oauth_state_idx": { + "name": "mcp_server_oauth_state_idx", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_server_oauth_mcp_server_id_mcp_servers_id_fk": { + "name": "mcp_server_oauth_mcp_server_id_mcp_servers_id_fk", + "tableFrom": "mcp_server_oauth", + "tableTo": "mcp_servers", + "columnsFrom": ["mcp_server_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_server_oauth_user_id_user_id_fk": { + "name": "mcp_server_oauth_user_id_user_id_fk", + "tableFrom": "mcp_server_oauth", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "mcp_server_oauth_workspace_id_workspace_id_fk": { + "name": "mcp_server_oauth_workspace_id_workspace_id_fk", + "tableFrom": "mcp_server_oauth", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_servers": { + "name": "mcp_servers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "transport": { + "name": "transport", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'headers'" + }, + "oauth_client_id": { + "name": "oauth_client_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oauth_client_secret": { + "name": "oauth_client_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "headers": { + "name": "headers", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "timeout": { + "name": "timeout", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 30000 + }, + "retries": { + "name": "retries", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 3 + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_connected": { + "name": "last_connected", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "connection_status": { + "name": "connection_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'disconnected'" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status_config": { + "name": "status_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "tool_count": { + "name": "tool_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_tools_refresh": { + "name": "last_tools_refresh", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_requests": { + "name": "total_requests", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_used": { + "name": "last_used", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_servers_workspace_enabled_idx": { + "name": "mcp_servers_workspace_enabled_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_servers_workspace_deleted_partial_idx": { + "name": "mcp_servers_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"mcp_servers\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_servers_workspace_id_workspace_id_fk": { + "name": "mcp_servers_workspace_id_workspace_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_servers_created_by_user_id_fk": { + "name": "mcp_servers_created_by_user_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.member": { + "name": "member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "member_user_id_unique": { + "name": "member_user_id_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "member_organization_id_idx": { + "name": "member_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "member_user_id_user_id_fk": { + "name": "member_user_id_user_id_fk", + "tableFrom": "member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "member_organization_id_organization_id_fk": { + "name": "member_organization_id_organization_id_fk", + "tableFrom": "member", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memory": { + "name": "memory", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "memory_key_idx": { + "name": "memory_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workspace_idx": { + "name": "memory_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workspace_key_idx": { + "name": "memory_workspace_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workspace_deleted_partial_idx": { + "name": "memory_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"memory\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "memory_workspace_id_workspace_id_fk": { + "name": "memory_workspace_id_workspace_id_fk", + "tableFrom": "memory", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_inbox_allowed_sender": { + "name": "mothership_inbox_allowed_sender", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "added_by": { + "name": "added_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "inbox_sender_ws_email_idx": { + "name": "inbox_sender_ws_email_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mothership_inbox_allowed_sender_workspace_id_workspace_id_fk": { + "name": "mothership_inbox_allowed_sender_workspace_id_workspace_id_fk", + "tableFrom": "mothership_inbox_allowed_sender", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mothership_inbox_allowed_sender_added_by_user_id_fk": { + "name": "mothership_inbox_allowed_sender_added_by_user_id_fk", + "tableFrom": "mothership_inbox_allowed_sender", + "tableTo": "user", + "columnsFrom": ["added_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_inbox_task": { + "name": "mothership_inbox_task", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "from_email": { + "name": "from_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "from_name": { + "name": "from_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body_preview": { + "name": "body_preview", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "body_text": { + "name": "body_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "body_html": { + "name": "body_html", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_message_id": { + "name": "email_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "in_reply_to": { + "name": "in_reply_to", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "response_message_id": { + "name": "response_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agentmail_message_id": { + "name": "agentmail_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'received'" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "trigger_job_id": { + "name": "trigger_job_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "result_summary": { + "name": "result_summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rejection_reason": { + "name": "rejection_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "has_attachments": { + "name": "has_attachments", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "cc_recipients": { + "name": "cc_recipients", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "processing_started_at": { + "name": "processing_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "inbox_task_ws_created_at_idx": { + "name": "inbox_task_ws_created_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inbox_task_ws_status_idx": { + "name": "inbox_task_ws_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inbox_task_response_msg_id_idx": { + "name": "inbox_task_response_msg_id_idx", + "columns": [ + { + "expression": "response_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inbox_task_email_msg_id_idx": { + "name": "inbox_task_email_msg_id_idx", + "columns": [ + { + "expression": "email_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mothership_inbox_task_workspace_id_workspace_id_fk": { + "name": "mothership_inbox_task_workspace_id_workspace_id_fk", + "tableFrom": "mothership_inbox_task", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mothership_inbox_task_chat_id_copilot_chats_id_fk": { + "name": "mothership_inbox_task_chat_id_copilot_chats_id_fk", + "tableFrom": "mothership_inbox_task", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_inbox_webhook": { + "name": "mothership_inbox_webhook", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "webhook_id": { + "name": "webhook_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "mothership_inbox_webhook_workspace_id_workspace_id_fk": { + "name": "mothership_inbox_webhook_workspace_id_workspace_id_fk", + "tableFrom": "mothership_inbox_webhook", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "mothership_inbox_webhook_workspace_id_unique": { + "name": "mothership_inbox_webhook_workspace_id_unique", + "nullsNotDistinct": false, + "columns": ["workspace_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_settings": { + "name": "mothership_settings", + "schema": "", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "mcp_tool_refs": { + "name": "mcp_tool_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "custom_tool_refs": { + "name": "custom_tool_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "skill_refs": { + "name": "skill_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mothership_settings_workspace_id_idx": { + "name": "mothership_settings_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mothership_settings_workspace_id_workspace_id_fk": { + "name": "mothership_settings_workspace_id_workspace_id_fk", + "tableFrom": "mothership_settings", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization": { + "name": "organization", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "whitelabel_settings": { + "name": "whitelabel_settings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "data_retention_settings": { + "name": "data_retention_settings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "org_usage_limit": { + "name": "org_usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "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": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.paused_executions": { + "name": "paused_executions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_snapshot": { + "name": "execution_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "pause_points": { + "name": "pause_points", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "total_pause_count": { + "name": "total_pause_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "resumed_count": { + "name": "resumed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'paused'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "paused_at": { + "name": "paused_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "next_resume_at": { + "name": "next_resume_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "paused_executions_workflow_id_idx": { + "name": "paused_executions_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paused_executions_status_idx": { + "name": "paused_executions_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paused_executions_execution_id_unique": { + "name": "paused_executions_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paused_executions_next_resume_at_idx": { + "name": "paused_executions_next_resume_at_idx", + "columns": [ + { + "expression": "next_resume_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "status = 'paused' AND next_resume_at IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "paused_executions_workflow_id_workflow_id_fk": { + "name": "paused_executions_workflow_id_workflow_id_fk", + "tableFrom": "paused_executions", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pending_credential_draft": { + "name": "pending_credential_draft", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pending_draft_user_provider_ws": { + "name": "pending_draft_user_provider_ws", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pending_credential_draft_user_id_user_id_fk": { + "name": "pending_credential_draft_user_id_user_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pending_credential_draft_workspace_id_workspace_id_fk": { + "name": "pending_credential_draft_workspace_id_workspace_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pending_credential_draft_credential_id_credential_id_fk": { + "name": "pending_credential_draft_credential_id_credential_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "credential", + "columnsFrom": ["credential_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission_group": { + "name": "permission_group", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "permission_group_created_by_idx": { + "name": "permission_group_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_organization_name_unique": { + "name": "permission_group_organization_name_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_organization_default_unique": { + "name": "permission_group_organization_default_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "is_default = true", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_group_organization_id_organization_id_fk": { + "name": "permission_group_organization_id_organization_id_fk", + "tableFrom": "permission_group", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_created_by_user_id_fk": { + "name": "permission_group_created_by_user_id_fk", + "tableFrom": "permission_group", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission_group_member": { + "name": "permission_group_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "permission_group_id": { + "name": "permission_group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "assigned_by": { + "name": "assigned_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assigned_at": { + "name": "assigned_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permission_group_member_group_id_idx": { + "name": "permission_group_member_group_id_idx", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_member_group_user_unique": { + "name": "permission_group_member_group_user_unique", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_member_organization_user_idx": { + "name": "permission_group_member_organization_user_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_group_member_permission_group_id_permission_group_id_fk": { + "name": "permission_group_member_permission_group_id_permission_group_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "permission_group", + "columnsFrom": ["permission_group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_member_organization_id_organization_id_fk": { + "name": "permission_group_member_organization_id_organization_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_member_user_id_user_id_fk": { + "name": "permission_group_member_user_id_user_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_member_assigned_by_user_id_fk": { + "name": "permission_group_member_assigned_by_user_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "user", + "columnsFrom": ["assigned_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission_group_workspace": { + "name": "permission_group_workspace", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "permission_group_id": { + "name": "permission_group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permission_group_workspace_workspace_id_idx": { + "name": "permission_group_workspace_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_workspace_group_workspace_unique": { + "name": "permission_group_workspace_group_workspace_unique", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_group_workspace_permission_group_id_permission_group_id_fk": { + "name": "permission_group_workspace_permission_group_id_permission_group_id_fk", + "tableFrom": "permission_group_workspace", + "tableTo": "permission_group", + "columnsFrom": ["permission_group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_workspace_workspace_id_workspace_id_fk": { + "name": "permission_group_workspace_workspace_id_workspace_id_fk", + "tableFrom": "permission_group_workspace", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_workspace_organization_id_organization_id_fk": { + "name": "permission_group_workspace_organization_id_organization_id_fk", + "tableFrom": "permission_group_workspace", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permissions": { + "name": "permissions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission_type": { + "name": "permission_type", + "type": "permission_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permissions_user_id_idx": { + "name": "permissions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_entity_idx": { + "name": "permissions_entity_idx", + "columns": [ + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_type_idx": { + "name": "permissions_user_entity_type_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_permission_idx": { + "name": "permissions_user_entity_permission_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "permission_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_idx": { + "name": "permissions_user_entity_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_unique_constraint": { + "name": "permissions_unique_constraint", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permissions_user_id_user_id_fk": { + "name": "permissions_user_id_user_id_fk", + "tableFrom": "permissions", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pinned_item": { + "name": "pinned_item", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pinned_at": { + "name": "pinned_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pinned_item_user_workspace_idx": { + "name": "pinned_item_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pinned_item_resource_idx": { + "name": "pinned_item_resource_idx", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pinned_item_user_resource_unique": { + "name": "pinned_item_user_resource_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pinned_item_user_id_user_id_fk": { + "name": "pinned_item_user_id_user_id_fk", + "tableFrom": "pinned_item", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pinned_item_workspace_id_workspace_id_fk": { + "name": "pinned_item_workspace_id_workspace_id_fk", + "tableFrom": "pinned_item", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.public_share": { + "name": "public_share", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowed_emails": { + "name": "allowed_emails", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "public_share_token_unique": { + "name": "public_share_token_unique", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "public_share_resource_unique": { + "name": "public_share_resource_unique", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "public_share_resource_id_idx": { + "name": "public_share_resource_id_idx", + "columns": [ + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "public_share_workspace_id_idx": { + "name": "public_share_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "public_share_workspace_id_workspace_id_fk": { + "name": "public_share_workspace_id_workspace_id_fk", + "tableFrom": "public_share", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "public_share_created_by_user_id_fk": { + "name": "public_share_created_by_user_id_fk", + "tableFrom": "public_share", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rate_limit_bucket": { + "name": "rate_limit_bucket", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tokens": { + "name": "tokens", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "last_refill_at": { + "name": "last_refill_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.resume_queue": { + "name": "resume_queue", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "paused_execution_id": { + "name": "paused_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_execution_id": { + "name": "parent_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "new_execution_id": { + "name": "new_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "context_id": { + "name": "context_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resume_input": { + "name": "resume_input", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "queued_at": { + "name": "queued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "failure_reason": { + "name": "failure_reason", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "resume_queue_parent_status_idx": { + "name": "resume_queue_parent_status_idx", + "columns": [ + { + "expression": "parent_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "resume_queue_new_execution_idx": { + "name": "resume_queue_new_execution_idx", + "columns": [ + { + "expression": "new_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "resume_queue_paused_execution_id_paused_executions_id_fk": { + "name": "resume_queue_paused_execution_id_paused_executions_id_fk", + "tableFrom": "resume_queue", + "tableTo": "paused_executions", + "columnsFrom": ["paused_execution_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "impersonated_by": { + "name": "impersonated_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "session_user_id_idx": { + "name": "session_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "session_token_idx": { + "name": "session_token_idx", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_active_organization_id_organization_id_fk": { + "name": "session_active_organization_id_organization_id_fk", + "tableFrom": "session", + "tableTo": "organization", + "columnsFrom": ["active_organization_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.settings": { + "name": "settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "theme": { + "name": "theme", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'system'" + }, + "auto_connect": { + "name": "auto_connect", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "telemetry_enabled": { + "name": "telemetry_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "email_preferences": { + "name": "email_preferences", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "billing_usage_notifications_enabled": { + "name": "billing_usage_notifications_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "show_training_controls": { + "name": "show_training_controls", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "super_user_mode_enabled": { + "name": "super_user_mode_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "mothership_environment": { + "name": "mothership_environment", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'default'" + }, + "error_notifications_enabled": { + "name": "error_notifications_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "snap_to_grid_size": { + "name": "snap_to_grid_size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "show_action_bar": { + "name": "show_action_bar", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "copilot_enabled_models": { + "name": "copilot_enabled_models", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "copilot_auto_allowed_tools": { + "name": "copilot_auto_allowed_tools", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "last_active_workspace_id": { + "name": "last_active_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "settings_user_id_user_id_fk": { + "name": "settings_user_id_user_id_fk", + "tableFrom": "settings", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "settings_user_id_unique": { + "name": "settings_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sim_trigger_state": { + "name": "sim_trigger_state", + "schema": "", + "columns": { + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_key": { + "name": "scope_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "last_fired_at": { + "name": "last_fired_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "sim_trigger_state_workflow_id_workflow_id_fk": { + "name": "sim_trigger_state_workflow_id_workflow_id_fk", + "tableFrom": "sim_trigger_state", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "sim_trigger_state_workflow_id_block_id_scope_key_pk": { + "name": "sim_trigger_state_workflow_id_block_id_scope_key_pk", + "columns": ["workflow_id", "block_id", "scope_key"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skill": { + "name": "skill", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "skill_workspace_name_unique": { + "name": "skill_workspace_name_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skill_workspace_id_workspace_id_fk": { + "name": "skill_workspace_id_workspace_id_fk", + "tableFrom": "skill", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "skill_user_id_user_id_fk": { + "name": "skill_user_id_user_id_fk", + "tableFrom": "skill", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.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 + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "schema": { + "name": "schema", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "max_rows": { + "name": "max_rows", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10000 + }, + "row_count": { + "name": "row_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "rows_version": { + "name": "rows_version", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_table_def_workspace_id_idx": { + "name": "user_table_def_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_folder_id_idx": { + "name": "user_table_def_folder_id_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_workspace_name_unique": { + "name": "user_table_def_workspace_name_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"user_table_definitions\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_archived_at_idx": { + "name": "user_table_def_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_workspace_archived_partial_idx": { + "name": "user_table_def_workspace_archived_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"user_table_definitions\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_table_definitions_workspace_id_workspace_id_fk": { + "name": "user_table_definitions_workspace_id_workspace_id_fk", + "tableFrom": "user_table_definitions", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_table_definitions_folder_id_folder_id_fk": { + "name": "user_table_definitions_folder_id_folder_id_fk", + "tableFrom": "user_table_definitions", + "tableTo": "folder", + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "user_table_definitions_created_by_user_id_fk": { + "name": "user_table_definitions_created_by_user_id_fk", + "tableFrom": "user_table_definitions", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_table_rows": { + "name": "user_table_rows", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "order_key": { + "name": "order_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "user_table_rows_tenant_data_gin_idx": { + "name": "user_table_rows_tenant_data_gin_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"data\" jsonb_path_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "user_table_rows_workspace_table_idx": { + "name": "user_table_rows_workspace_table_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_position_idx": { + "name": "user_table_rows_table_position_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_order_key_idx": { + "name": "user_table_rows_table_order_key_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "order_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_id_id_idx": { + "name": "user_table_rows_table_id_id_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_table_rows_table_id_user_table_definitions_id_fk": { + "name": "user_table_rows_table_id_user_table_definitions_id_fk", + "tableFrom": "user_table_rows", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_table_rows_workspace_id_workspace_id_fk": { + "name": "user_table_rows_workspace_id_workspace_id_fk", + "tableFrom": "user_table_rows", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_table_rows_created_by_user_id_fk": { + "name": "user_table_rows_created_by_user_id_fk", + "tableFrom": "user_table_rows", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "verification_expires_at_idx": { + "name": "verification_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.waitlist": { + "name": "waitlist", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "waitlist_email_unique": { + "name": "waitlist_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhook": { + "name": "webhook", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "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": {} + } + }, + "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": {}, + "isRLSEnabled": false + }, + "public.workflow": { + "name": "workflow", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_synced": { + "name": "last_synced", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "is_deployed": { + "name": "is_deployed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deployed_at": { + "name": "deployed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "is_public_api": { + "name": "is_public_api", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "run_count": { + "name": "run_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workflow_user_id_idx": { + "name": "workflow_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_id_idx": { + "name": "workflow_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_user_workspace_idx": { + "name": "workflow_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_folder_name_active_unique": { + "name": "workflow_workspace_folder_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"folder_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_sort_idx": { + "name": "workflow_folder_sort_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_archived_at_idx": { + "name": "workflow_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_archived_partial_idx": { + "name": "workflow_workspace_archived_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_user_id_user_id_fk": { + "name": "workflow_user_id_user_id_fk", + "tableFrom": "workflow", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_workspace_id_workspace_id_fk": { + "name": "workflow_workspace_id_workspace_id_fk", + "tableFrom": "workflow", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_blocks": { + "name": "workflow_blocks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position_x": { + "name": "position_x", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "position_y": { + "name": "position_y", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "horizontal_handles": { + "name": "horizontal_handles", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "is_wide": { + "name": "is_wide", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "advanced_mode": { + "name": "advanced_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "trigger_mode": { + "name": "trigger_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "height": { + "name": "height", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "sub_blocks": { + "name": "sub_blocks", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "outputs": { + "name": "outputs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_blocks_workflow_id_idx": { + "name": "workflow_blocks_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_blocks_type_idx": { + "name": "workflow_blocks_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_blocks_workflow_id_workflow_id_fk": { + "name": "workflow_blocks_workflow_id_workflow_id_fk", + "tableFrom": "workflow_blocks", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_checkpoints": { + "name": "workflow_checkpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_state": { + "name": "workflow_state", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_checkpoints_user_id_idx": { + "name": "workflow_checkpoints_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_workflow_id_idx": { + "name": "workflow_checkpoints_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_chat_id_idx": { + "name": "workflow_checkpoints_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_message_id_idx": { + "name": "workflow_checkpoints_message_id_idx", + "columns": [ + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_user_workflow_idx": { + "name": "workflow_checkpoints_user_workflow_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_workflow_chat_idx": { + "name": "workflow_checkpoints_workflow_chat_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_created_at_idx": { + "name": "workflow_checkpoints_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_chat_created_at_idx": { + "name": "workflow_checkpoints_chat_created_at_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_checkpoints_user_id_user_id_fk": { + "name": "workflow_checkpoints_user_id_user_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_checkpoints_workflow_id_workflow_id_fk": { + "name": "workflow_checkpoints_workflow_id_workflow_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_checkpoints_chat_id_copilot_chats_id_fk": { + "name": "workflow_checkpoints_chat_id_copilot_chats_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_deployment_version": { + "name": "workflow_deployment_version", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workflow_deployment_version_workflow_version_unique": { + "name": "workflow_deployment_version_workflow_version_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_version_workflow_active_idx": { + "name": "workflow_deployment_version_workflow_active_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_version_created_at_idx": { + "name": "workflow_deployment_version_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_deployment_version_workflow_id_workflow_id_fk": { + "name": "workflow_deployment_version_workflow_id_workflow_id_fk", + "tableFrom": "workflow_deployment_version", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_edges": { + "name": "workflow_edges", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_block_id": { + "name": "source_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_block_id": { + "name": "target_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_handle": { + "name": "source_handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_handle": { + "name": "target_handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_edges_workflow_id_idx": { + "name": "workflow_edges_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_edges_workflow_source_idx": { + "name": "workflow_edges_workflow_source_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_edges_workflow_target_idx": { + "name": "workflow_edges_workflow_target_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_edges_workflow_id_workflow_id_fk": { + "name": "workflow_edges_workflow_id_workflow_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_edges_source_block_id_workflow_blocks_id_fk": { + "name": "workflow_edges_source_block_id_workflow_blocks_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow_blocks", + "columnsFrom": ["source_block_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_edges_target_block_id_workflow_blocks_id_fk": { + "name": "workflow_edges_target_block_id_workflow_blocks_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow_blocks", + "columnsFrom": ["target_block_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_execution_logs": { + "name": "workflow_execution_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state_snapshot_id": { + "name": "state_snapshot_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_duration_ms": { + "name": "total_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "execution_data": { + "name": "execution_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "cost": { + "name": "cost", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "cost_total": { + "name": "cost_total", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "models_used": { + "name": "models_used", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "files": { + "name": "files", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_execution_logs_workflow_id_idx": { + "name": "workflow_execution_logs_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_state_snapshot_id_idx": { + "name": "workflow_execution_logs_state_snapshot_id_idx", + "columns": [ + { + "expression": "state_snapshot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_deployment_version_id_idx": { + "name": "workflow_execution_logs_deployment_version_id_idx", + "columns": [ + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_trigger_idx": { + "name": "workflow_execution_logs_trigger_idx", + "columns": [ + { + "expression": "trigger", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_level_idx": { + "name": "workflow_execution_logs_level_idx", + "columns": [ + { + "expression": "level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_started_at_idx": { + "name": "workflow_execution_logs_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_execution_id_unique": { + "name": "workflow_execution_logs_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workflow_started_at_idx": { + "name": "workflow_execution_logs_workflow_started_at_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workspace_started_at_idx": { + "name": "workflow_execution_logs_workspace_started_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workspace_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": {} + } + }, + "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 + }, + "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_source_user_id_user_id_fk": { + "name": "workflow_schedule_source_user_id_user_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "user", + "columnsFrom": ["source_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_source_workspace_id_workspace_id_fk": { + "name": "workflow_schedule_source_workspace_id_workspace_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workspace", + "columnsFrom": ["source_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_subflows": { + "name": "workflow_subflows", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_subflows_workflow_id_idx": { + "name": "workflow_subflows_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_subflows_workflow_type_idx": { + "name": "workflow_subflows_workflow_type_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_subflows_workflow_id_workflow_id_fk": { + "name": "workflow_subflows_workflow_id_workflow_id_fk", + "tableFrom": "workflow_subflows", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace": { + "name": "workspace", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'#33C482'" + }, + "logo_url": { + "name": "logo_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_mode": { + "name": "workspace_mode", + "type": "workspace_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'grandfathered_shared'" + }, + "billed_account_user_id": { + "name": "billed_account_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "allow_personal_api_keys": { + "name": "allow_personal_api_keys", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "inbox_enabled": { + "name": "inbox_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "inbox_address": { + "name": "inbox_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "inbox_provider_id": { + "name": "inbox_provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "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": {}, + "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 + }, + "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 + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "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_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.folder_resource_type": { + "name": "folder_resource_type", + "schema": "public", + "values": ["workflow", "file", "knowledge_base", "table"] + }, + "public.invitation_kind": { + "name": "invitation_kind", + "schema": "public", + "values": ["organization", "workspace"] + }, + "public.invitation_membership_intent": { + "name": "invitation_membership_intent", + "schema": "public", + "values": ["internal", "external"] + }, + "public.invitation_status": { + "name": "invitation_status", + "schema": "public", + "values": ["pending", "accepted", "rejected", "cancelled", "expired"] + }, + "public.permission_type": { + "name": "permission_type", + "schema": "public", + "values": ["admin", "write", "read"] + }, + "public.usage_log_category": { + "name": "usage_log_category", + "schema": "public", + "values": ["model", "fixed", "tool"] + }, + "public.usage_log_source": { + "name": "usage_log_source", + "schema": "public", + "values": [ + "workflow", + "wand", + "copilot", + "workspace-chat", + "mcp_copilot", + "mothership_block", + "knowledge-base", + "voice-input", + "enrichment" + ] + }, + "public.workspace_fork_promote_direction": { + "name": "workspace_fork_promote_direction", + "schema": "public", + "values": ["push", "pull"] + }, + "public.workspace_fork_resource_type": { + "name": "workspace_fork_resource_type", + "schema": "public", + "values": [ + "workflow", + "oauth_credential", + "service_account_credential", + "env_var", + "table", + "knowledge_base", + "knowledge_document", + "file", + "mcp_server", + "workflow_mcp_server", + "custom_tool", + "skill" + ] + }, + "public.workspace_mode": { + "name": "workspace_mode", + "schema": "public", + "values": ["personal", "organization", "grandfathered_shared"] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/db/migrations/meta/_journal.json b/packages/db/migrations/meta/_journal.json index f9f91a44cc1..afcbd56e9bd 100644 --- a/packages/db/migrations/meta/_journal.json +++ b/packages/db/migrations/meta/_journal.json @@ -1814,6 +1814,13 @@ "when": 1783722352108, "tag": "0259_slack_native_routing", "breakpoints": true + }, + { + "idx": 260, + "version": "7", + "when": 1783730317120, + "tag": "0260_smart_wrecker", + "breakpoints": true } ] } diff --git a/packages/db/schema.ts b/packages/db/schema.ts index d18762a071e..537eae2cbfd 100644 --- a/packages/db/schema.ts +++ b/packages/db/schema.ts @@ -115,6 +115,102 @@ export const verification = pgTable( }) ) +export const folderResourceTypeEnum = pgEnum('folder_resource_type', [ + 'workflow', + 'file', + 'knowledge_base', + 'table', +]) + +/** + * Generic folder hierarchy shared by workflows, files, knowledge bases, and tables. + * Supersedes the resource-specific `workflowFolder`/`workspaceFileFolder` tables (see + * deprecation notes on those below). `resourceType` is a real `pgEnum` here — unlike + * `pinnedItem.resourceType` below — because the set of folder-bearing resources is + * small and fixed today; a same-resourceType parent invariant is additionally enforced + * by the `folder_parent_resource_type_match` trigger declared in the migration SQL. + */ +export const folder = pgTable( + 'folder', + { + id: text('id').primaryKey(), + resourceType: folderResourceTypeEnum('resource_type').notNull(), + name: text('name').notNull(), + userId: text('user_id') + .notNull() + .references(() => user.id, { onDelete: 'cascade' }), + workspaceId: text('workspace_id') + .notNull() + .references(() => workspace.id, { onDelete: 'cascade' }), + parentId: text('parent_id').references((): AnyPgColumn => folder.id, { + onDelete: 'set null', + }), + // locked is used generically across all four resourceTypes (lock-cascade feature) — + // the ancestor-walk lock check and restore-time guard are resourceType-parameterized, + // not workflow-specific. `color` and `isExpanded` were dropped — color had no UI + // consumer and isExpanded's real state lives client-side in the folders Zustand + // store, never read from the DB. + locked: boolean('locked').notNull().default(false), + sortOrder: integer('sort_order').notNull().default(0), + createdAt: timestamp('created_at').notNull().defaultNow(), + updatedAt: timestamp('updated_at').notNull().defaultNow(), + deletedAt: timestamp('deleted_at'), + }, + (table) => ({ + userIdx: index('folder_user_idx').on(table.userId), + resourceTypeIdx: index('folder_resource_type_idx').on(table.resourceType), + workspaceResourceParentIdx: index('folder_workspace_resource_parent_idx').on( + table.workspaceId, + table.resourceType, + table.parentId + ), + parentSortIdx: index('folder_parent_sort_idx').on(table.parentId, table.sortOrder), + deletedAtIdx: index('folder_deleted_at_idx').on(table.deletedAt), + workspaceDeletedAtPartialIdx: index('folder_workspace_deleted_partial_idx') + .on(table.workspaceId, table.deletedAt) + .where(sql`${table.deletedAt} IS NOT NULL`), + workspaceResourceParentNameActiveUnique: uniqueIndex( + 'folder_workspace_resource_parent_name_active_unique' + ) + .on(table.workspaceId, table.resourceType, sql`coalesce(${table.parentId}, '')`, table.name) + .where(sql`${table.deletedAt} IS NULL`), + }) +) + +/** + * Per-user pinning of folders and folder-bearing resources (workflows, files, + * knowledge bases, tables). Polymorphic on `resourceType`, following the same shape + * as `publicShare.resourceType` below — deliberately plain `text`, not a `pgEnum` + * (unlike `folder.resourceType`), because pinning is expected to extend to more kinds + * faster than folders will, and must also accept `'folder'` itself as a pinnable kind. + */ +export const pinnedItem = pgTable( + 'pinned_item', + { + id: text('id').primaryKey(), + userId: text('user_id') + .notNull() + .references(() => user.id, { onDelete: 'cascade' }), + workspaceId: text('workspace_id') + .notNull() + .references(() => workspace.id, { onDelete: 'cascade' }), + resourceType: text('resource_type').notNull(), // 'folder' | 'workflow' | 'file' | 'knowledge_base' | 'table' + resourceId: text('resource_id').notNull(), + pinnedAt: timestamp('pinned_at').notNull().defaultNow(), + }, + (table) => ({ + userWorkspaceIdx: index('pinned_item_user_workspace_idx').on(table.userId, table.workspaceId), + resourceIdx: index('pinned_item_resource_idx').on(table.resourceType, table.resourceId), + userResourceUnique: uniqueIndex('pinned_item_user_resource_unique').on( + table.userId, + table.resourceType, + table.resourceId + ), + }) +) + +// DEPRECATED: superseded by `folder` table (resourceType='workflow'). Will be dropped +// in a follow-up migration once staging is verified. export const workflowFolder = pgTable( 'workflow_folder', { @@ -157,7 +253,12 @@ export const workflow = pgTable( .notNull() .references(() => user.id, { onDelete: 'cascade' }), workspaceId: text('workspace_id').references(() => workspace.id, { onDelete: 'cascade' }), - folderId: text('folder_id').references(() => workflowFolder.id, { onDelete: 'set null' }), + // contract-pending(after the generic-folders expand migration 0260 is fully deployed): + // re-add `.references(() => folder.id, { onDelete: 'set null' })` once no old-code + // pods can still write a workflow_folder-only id here. Expand only drops the old + // (now-wrong) FK target; adding the new one in the same deploy would reject writes + // from still-running old app code that hasn't cut over to the generic `folder` table. + folderId: text('folder_id'), sortOrder: integer('sort_order').notNull().default(0), name: text('name').notNull(), description: text('description'), @@ -1638,6 +1739,8 @@ export const workspaceFile = pgTable( }) ) +// DEPRECATED: superseded by `folder` table (resourceType='file'). Will be dropped +// in a follow-up migration once staging is verified. export const workspaceFileFolder = pgTable( 'workspace_file_folders', { @@ -1687,9 +1790,11 @@ export const workspaceFiles = pgTable( .notNull() .references(() => user.id, { onDelete: 'cascade' }), workspaceId: text('workspace_id').references(() => workspace.id, { onDelete: 'cascade' }), - folderId: text('folder_id').references(() => workspaceFileFolder.id, { - onDelete: 'set null', - }), + // contract-pending(after the generic-folders expand migration 0260 is fully deployed): + // re-add `.references(() => folder.id, { onDelete: 'set null' })` once no old-code + // pods can still write a workspace_file_folders-only id here — see the matching + // marker on workflow.folderId. + folderId: text('folder_id'), context: text('context').notNull(), // 'workspace', 'mothership', 'copilot', 'chat', 'knowledge-base', 'profile-pictures', 'general', 'execution' chatId: uuid('chat_id').references(() => copilotChats.id, { onDelete: 'cascade' }), originalName: text('original_name').notNull(), @@ -1705,6 +1810,7 @@ export const workspaceFiles = pgTable( displayName: text('display_name'), contentType: text('content_type').notNull(), size: integer('size').notNull(), + locked: boolean('locked').notNull().default(false), deletedAt: timestamp('deleted_at'), uploadedAt: timestamp('uploaded_at').notNull().defaultNow(), updatedAt: timestamp('updated_at').notNull().defaultNow(), @@ -1901,6 +2007,7 @@ export const knowledgeBase = pgTable( .notNull() .references(() => user.id, { onDelete: 'cascade' }), workspaceId: text('workspace_id').references(() => workspace.id, { onDelete: 'cascade' }), + folderId: text('folder_id').references(() => folder.id, { onDelete: 'set null' }), name: text('name').notNull(), description: text('description'), @@ -1916,6 +2023,8 @@ export const knowledgeBase = pgTable( .notNull() .default('{"maxSize": 1024, "minSize": 1, "overlap": 200}'), + locked: boolean('locked').notNull().default(false), + // Soft delete support deletedAt: timestamp('deleted_at'), @@ -1929,6 +2038,7 @@ export const knowledgeBase = pgTable( workspaceIdIdx: index('kb_workspace_id_idx').on(table.workspaceId), // Composite index for user's workspaces userWorkspaceIdx: index('kb_user_workspace_idx').on(table.userId, table.workspaceId), + folderIdIdx: index('kb_folder_id_idx').on(table.folderId), // Index for soft delete filtering deletedAtIdx: index('kb_deleted_at_idx').on(table.deletedAt), workspaceDeletedAtPartialIdx: index('kb_workspace_deleted_partial_idx') @@ -3367,6 +3477,7 @@ export const userTableDefinitions = pgTable( workspaceId: text('workspace_id') .notNull() .references(() => workspace.id, { onDelete: 'cascade' }), + folderId: text('folder_id').references(() => folder.id, { onDelete: 'set null' }), name: text('name').notNull(), description: text('description'), /** @@ -3390,6 +3501,7 @@ export const userTableDefinitions = pgTable( * from application code — the trigger is the only writer (bypass-proof). */ rowsVersion: bigint('rows_version', { mode: 'number' }).notNull().default(0), + locked: boolean('locked').notNull().default(false), archivedAt: timestamp('archived_at'), createdBy: text('created_by') .notNull() @@ -3399,6 +3511,7 @@ export const userTableDefinitions = pgTable( }, (table) => ({ workspaceIdIdx: index('user_table_def_workspace_id_idx').on(table.workspaceId), + folderIdIdx: index('user_table_def_folder_id_idx').on(table.folderId), workspaceNameUnique: uniqueIndex('user_table_def_workspace_name_unique') .on(table.workspaceId, table.name) .where(sql`${table.archivedAt} IS NULL`), diff --git a/packages/platform-authz/package.json b/packages/platform-authz/package.json index fd989954ec1..10d9c427ea0 100644 --- a/packages/platform-authz/package.json +++ b/packages/platform-authz/package.json @@ -21,6 +21,10 @@ "./workflow": { "types": "./src/workflow.ts", "default": "./src/workflow.ts" + }, + "./resource-lock": { + "types": "./src/resource-lock.ts", + "default": "./src/resource-lock.ts" } }, "scripts": { diff --git a/packages/platform-authz/src/resource-lock.ts b/packages/platform-authz/src/resource-lock.ts new file mode 100644 index 00000000000..eacb1d0adea --- /dev/null +++ b/packages/platform-authz/src/resource-lock.ts @@ -0,0 +1,308 @@ +import { + db, + folder, + type folderResourceTypeEnum, + knowledgeBase, + userTableDefinitions, + workflow, + workspaceFiles, +} from '@sim/db' +import type * as schema from '@sim/db/schema' +import type { ExtractTablesWithRelations } from 'drizzle-orm' +import { and, eq, isNull } from 'drizzle-orm' +import type { PgColumn, PgTable, PgTransaction } from 'drizzle-orm/pg-core' +import type { PostgresJsQueryResultHKT } from 'drizzle-orm/postgres-js' + +export type FolderResourceType = (typeof folderResourceTypeEnum.enumValues)[number] + +/** Allows a caller to join its own transaction instead of the module-level `db`. */ +type DbOrTx = + | typeof db + | PgTransaction< + PostgresJsQueryResultHKT, + typeof schema, + ExtractTablesWithRelations + > + +export interface LockStatus { + locked: boolean + directLocked: boolean + inheritedLocked: boolean + lockedBy: 'resource' | 'folder' | null + lockedFolderId: string | null +} + +/** + * `status = 423` (Locked) for every resourceType. `inherited` distinguishes a + * direct lock on the resource/folder itself from one inherited from an ancestor + * folder, so callers (and `instanceof` subclasses in `workflow.ts`) can render a + * more specific message. + */ +export class ResourceLockedError extends Error { + readonly status = 423 + readonly resourceType: FolderResourceType + readonly inherited: boolean + + constructor(resourceType: FolderResourceType, inherited: boolean, message?: string) { + super(message ?? `${resourceType} is locked`) + this.name = 'ResourceLockedError' + this.resourceType = resourceType + this.inherited = inherited + } +} + +interface ResourceLockConfig { + table: PgTable + idColumn: PgColumn + lockedColumn: PgColumn + folderIdColumn: PgColumn +} + +/** + * One row per `FolderResourceType`, capturing the single delta (table + columns) + * needed to run the shared lock-status/assert algorithms below. Mirrors the + * config-driven pattern established by `PINNED_RESOURCE_LOOKUP` + * (`apps/sim/app/api/pinned-items/route.ts`) and `FolderCascadeConfig` + * (`apps/sim/lib/folders/orchestration.ts`). + */ +const RESOURCE_LOCK_LOOKUP: Record = { + workflow: { + table: workflow, + idColumn: workflow.id, + lockedColumn: workflow.locked, + folderIdColumn: workflow.folderId, + }, + file: { + table: workspaceFiles, + idColumn: workspaceFiles.id, + lockedColumn: workspaceFiles.locked, + folderIdColumn: workspaceFiles.folderId, + }, + knowledge_base: { + table: knowledgeBase, + idColumn: knowledgeBase.id, + lockedColumn: knowledgeBase.locked, + folderIdColumn: knowledgeBase.folderId, + }, + table: { + table: userTableDefinitions, + idColumn: userTableDefinitions.id, + lockedColumn: userTableDefinitions.locked, + folderIdColumn: userTableDefinitions.folderId, + }, +} + +const UNLOCKED_STATUS: LockStatus = { + locked: false, + directLocked: false, + inheritedLocked: false, + lockedBy: null, + lockedFolderId: null, +} + +/** + * Walks the folder ancestor chain starting at `folderId`, returning the first + * locked folder found (direct or inherited) for the given `resourceType`. + */ +export async function getFolderLockStatus( + folderId: string | null, + resourceType: FolderResourceType, + dbClient: DbOrTx = db +): Promise { + if (!folderId) return UNLOCKED_STATUS + + let currentFolderId: string | null = folderId + let isDirect = true + const visited = new Set() + let directLock: LockStatus | null = null + + while (currentFolderId && !visited.has(currentFolderId)) { + visited.add(currentFolderId) + // `FOR UPDATE` row-locks each ancestor for the rest of the caller's transaction -- + // without it, a lock toggled on this folder after this read but before the + // caller's write commits could still be silently bypassed. Callers that pass the + // default `db` client (a pre-check outside any transaction) get a single-statement + // implicit transaction, so the lock is released immediately and this is a no-op; + // callers that pass `tx` hold it until their transaction resolves. + const [folderRow] = await dbClient + .select({ + id: folder.id, + parentId: folder.parentId, + locked: folder.locked, + }) + .from(folder) + .where( + and( + eq(folder.id, currentFolderId), + eq(folder.resourceType, resourceType), + isNull(folder.deletedAt) + ) + ) + .for('update') + .limit(1) + + if (!folderRow) break + if (folderRow.locked) { + if (isDirect) { + // Remember the direct lock but keep walking -- an inherited lock further up + // the chain must still be reported even when the direct lock on `folderId` + // itself is being bypassed by the caller (see `assertFolderMutableUnlessUnlocking`). + directLock = { + locked: true, + directLocked: true, + inheritedLocked: false, + lockedBy: 'folder', + lockedFolderId: folderRow.id, + } + } else { + return { + locked: true, + directLocked: false, + inheritedLocked: true, + lockedBy: 'folder', + lockedFolderId: folderRow.id, + } + } + } + + currentFolderId = folderRow.parentId + isDirect = false + } + + return directLock ?? UNLOCKED_STATUS +} + +/** + * Checks the resource's own `locked` column first, falling back to + * {@link getFolderLockStatus} on its containing folder chain when unset. + */ +export async function getResourceLockStatus( + resourceType: FolderResourceType, + resourceId: string, + dbClient: DbOrTx = db +): Promise { + const config = RESOURCE_LOCK_LOOKUP[resourceType] + + // See the `FOR UPDATE` comment in `getFolderLockStatus` above -- same reasoning + // applies to the resource's own row. + const [row] = await dbClient + .select({ + locked: config.lockedColumn, + folderId: config.folderIdColumn, + }) + .from(config.table) + .where(eq(config.idColumn, resourceId)) + .for('update') + .limit(1) + + if (!row) return UNLOCKED_STATUS + + // Folder-inherited locks are checked first and unconditionally -- a lock on the + // resource's own row can be bypassed by a request that is itself unlocking it + // (see `assertResourceMutableUnlessUnlocking`), but a lock further up the folder + // chain never can be, so it must never be short-circuited by a direct resource lock. + const folderStatus = await getFolderLockStatus( + row.folderId as string | null, + resourceType, + dbClient + ) + if (folderStatus.locked) { + return { + locked: true, + directLocked: false, + inheritedLocked: true, + lockedBy: 'folder', + lockedFolderId: folderStatus.lockedFolderId, + } + } + + if (row.locked) { + return { + locked: true, + directLocked: true, + inheritedLocked: false, + lockedBy: 'resource', + lockedFolderId: null, + } + } + + return UNLOCKED_STATUS +} + +export async function assertFolderMutable( + folderId: string | null, + resourceType: FolderResourceType, + dbClient: DbOrTx = db +): Promise { + const status = await getFolderLockStatus(folderId, resourceType, dbClient) + if (status.locked) { + throw new ResourceLockedError( + resourceType, + status.inheritedLocked, + status.inheritedLocked ? 'Folder is locked by an ancestor folder' : 'Folder is locked' + ) + } +} + +export async function assertResourceMutable( + resourceType: FolderResourceType, + resourceId: string, + dbClient: DbOrTx = db +): Promise { + const status = await getResourceLockStatus(resourceType, resourceId, dbClient) + if (status.locked) { + throw new ResourceLockedError( + resourceType, + status.lockedBy === 'folder', + status.lockedBy === 'folder' + ? `${resourceType} is locked by its containing folder` + : `${resourceType} is locked` + ) + } +} + +/** + * Shared bypass for the `*MutableUnlessUnlocking` wrappers below: swallows a + * DIRECT-lock rejection from `assert` when `unlocking` is true (the caller's own + * request is clearing that lock in the same atomic write, so a stale pre-write + * read of its own `locked` flag must not block the rest of that write), while an + * INHERITED lock still blocks regardless, since clearing this resource/folder's + * own flag has no effect on its ancestors. + */ +async function assertMutableUnlessUnlocking( + assert: () => Promise, + unlocking: boolean +): Promise { + try { + await assert() + } catch (error) { + if (unlocking && error instanceof ResourceLockedError && !error.inherited) return + throw error + } +} + +/** Like {@link assertFolderMutable}, but bypassed by an unlocking write -- see {@link assertMutableUnlessUnlocking}. */ +export async function assertFolderMutableUnlessUnlocking( + folderId: string | null, + resourceType: FolderResourceType, + unlocking: boolean, + dbClient: DbOrTx = db +): Promise { + return assertMutableUnlessUnlocking( + () => assertFolderMutable(folderId, resourceType, dbClient), + unlocking + ) +} + +/** Resource-level counterpart to {@link assertFolderMutableUnlessUnlocking}. */ +export async function assertResourceMutableUnlessUnlocking( + resourceType: FolderResourceType, + resourceId: string, + unlocking: boolean, + dbClient: DbOrTx = db +): Promise { + return assertMutableUnlessUnlocking( + () => assertResourceMutable(resourceType, resourceId, dbClient), + unlocking + ) +} diff --git a/packages/platform-authz/src/workflow.ts b/packages/platform-authz/src/workflow.ts index c1d643a5850..484faea65af 100644 --- a/packages/platform-authz/src/workflow.ts +++ b/packages/platform-authz/src/workflow.ts @@ -1,5 +1,10 @@ -import { db, workflow, workflowFolder, workspace } from '@sim/db' +import { db, folder, workflow, workspace } from '@sim/db' import { and, eq, isNull } from 'drizzle-orm' +import { + type LockStatus as GenericLockStatus, + getFolderLockStatus as getGenericFolderLockStatus, + ResourceLockedError, +} from './resource-lock' import { type PermissionType, permissionSatisfies, @@ -62,20 +67,22 @@ export async function assertActiveWorkflowContext( type WorkflowRecord = typeof workflow.$inferSelect -export class WorkflowLockedError extends Error { - readonly status = 423 - - constructor(message = 'Workflow is locked') { - super(message) +/** + * Workflow-flavored subclass of the generic {@link ResourceLockedError} so + * existing `instanceof WorkflowLockedError` catch blocks (~30 call sites) + * keep working unchanged after the locking engine was consolidated into + * `resource-lock.ts`. + */ +export class WorkflowLockedError extends ResourceLockedError { + constructor(message = 'Workflow is locked', inherited = false) { + super('workflow', inherited, message) this.name = 'WorkflowLockedError' } } -export class FolderLockedError extends Error { - readonly status = 423 - - constructor(message = 'Folder is locked') { - super(message) +export class FolderLockedError extends ResourceLockedError { + constructor(message = 'Folder is locked', inherited = false) { + super('workflow', inherited, message) this.name = 'FolderLockedError' } } @@ -88,57 +95,33 @@ export interface LockStatus { lockedFolderId: string | null } -export async function getFolderLockStatus(folderId: string | null): Promise { - if (!folderId) { - return { - locked: false, - directLocked: false, - inheritedLocked: false, - lockedBy: null, - lockedFolderId: null, - } - } - - let currentFolderId: string | null = folderId - let isDirect = true - const visited = new Set() - - while (currentFolderId && !visited.has(currentFolderId)) { - visited.add(currentFolderId) - const [folder] = await db - .select({ - id: workflowFolder.id, - parentId: workflowFolder.parentId, - locked: workflowFolder.locked, - }) - .from(workflowFolder) - .where(and(eq(workflowFolder.id, currentFolderId), isNull(workflowFolder.archivedAt))) - .limit(1) - - if (!folder) break - if (folder.locked) { - return { - locked: true, - directLocked: isDirect, - inheritedLocked: !isDirect, - lockedBy: 'folder', - lockedFolderId: folder.id, - } - } - - currentFolderId = folder.parentId - isDirect = false - } - +/** + * `status.lockedBy === 'resource'` is unreachable in practice -- `getGenericFolderLockStatus` + * only ever returns `'folder' | null` -- but the shared {@link GenericLockStatus} type is + * also used by `getResourceLockStatus` (which does return `'resource'`), so the branch is + * kept for type-level exhaustiveness. + */ +function toWorkflowLockStatus(status: GenericLockStatus): LockStatus { return { - locked: false, - directLocked: false, - inheritedLocked: false, - lockedBy: null, - lockedFolderId: null, + ...status, + lockedBy: status.lockedBy === 'resource' ? 'workflow' : status.lockedBy, } } +/** + * Thin `resourceType: 'workflow'` wrapper over the generic folder-chain walk + * in `resource-lock.ts` — kept so the ~30 existing call sites importing from + * `@sim/platform-authz/workflow` don't need a rename sweep. + */ +export async function getFolderLockStatus(folderId: string | null): Promise { + return toWorkflowLockStatus(await getGenericFolderLockStatus(folderId, 'workflow')) +} + +/** + * Checks `workflow.locked` on the row itself (only for a live, non-archived + * workflow — archived workflows are expected to already have been rejected + * upstream by `getActiveWorkflowContext`), falling back to the folder chain. + */ export async function getWorkflowLockStatus(workflowId: string): Promise { const [wf] = await db .select({ @@ -178,7 +161,8 @@ export async function assertWorkflowMutable(workflowId: string): Promise { throw new WorkflowLockedError( status.lockedBy === 'folder' ? 'Workflow is locked by its containing folder' - : 'Workflow is locked' + : 'Workflow is locked', + status.lockedBy === 'folder' ) } } @@ -187,7 +171,8 @@ export async function assertFolderMutable(folderId: string | null): Promise { if (!folderId) return true - const [folder] = await db + const [folderRow] = await db .select({ - workspaceId: workflowFolder.workspaceId, - archivedAt: workflowFolder.archivedAt, + workspaceId: folder.workspaceId, + deletedAt: folder.deletedAt, }) - .from(workflowFolder) - .where(eq(workflowFolder.id, folderId)) + .from(folder) + .where(and(eq(folder.id, folderId), eq(folder.resourceType, 'workflow'))) .limit(1) - return Boolean(folder && folder.workspaceId === workspaceId && !folder.archivedAt) + return Boolean(folderRow && folderRow.workspaceId === workspaceId && !folderRow.deletedAt) } /** diff --git a/packages/testing/src/mocks/index.ts b/packages/testing/src/mocks/index.ts index 5ddbd73aa95..74c10702856 100644 --- a/packages/testing/src/mocks/index.ts +++ b/packages/testing/src/mocks/index.ts @@ -106,6 +106,8 @@ export { requestUtilsMock, requestUtilsMockFns, } from './request.mock' +// Resource-lock authz package mocks (for @sim/platform-authz/resource-lock) +export { resourceLockMock, resourceLockMockFns } from './resource-lock.mock' // Schema mocks export { schemaMock } from './schema.mock' // Socket mocks diff --git a/packages/testing/src/mocks/resource-lock.mock.ts b/packages/testing/src/mocks/resource-lock.mock.ts new file mode 100644 index 00000000000..4d18c6cac65 --- /dev/null +++ b/packages/testing/src/mocks/resource-lock.mock.ts @@ -0,0 +1,111 @@ +import { vi } from 'vitest' + +/** + * Real `ResourceLockedError` shape used by tests so `instanceof` checks in + * route handlers behave the same as in production. Mirrors the class exported + * by `@sim/platform-authz/resource-lock`. + */ +export class MockResourceLockedError extends Error { + readonly status = 423 + readonly resourceType: string + readonly inherited: boolean + + constructor(resourceType: string, inherited: boolean, message?: string) { + super(message ?? `${resourceType} is locked`) + this.name = 'ResourceLockedError' + this.resourceType = resourceType + this.inherited = inherited + } +} + +const unlockedStatus = { + locked: false, + directLocked: false, + inheritedLocked: false, + lockedBy: null as 'resource' | 'folder' | null, + lockedFolderId: null as string | null, +} + +/** + * Controllable mocks for the `@sim/platform-authz/resource-lock` entry. + * + * Defaults assume permissive access (no lock). Override with + * `mockResolvedValue` per test when exercising the lock paths. + * + * @example + * ```ts + * import { resourceLockMockFns } from '@sim/testing' + * + * resourceLockMockFns.mockAssertResourceMutable.mockRejectedValue( + * new MockResourceLockedError('knowledge_base', false) + * ) + * ``` + */ +const mockAssertFolderMutable = vi.fn().mockResolvedValue(undefined) +const mockAssertResourceMutable = vi.fn().mockResolvedValue(undefined) + +/** + * Real wrapper logic (not a bare passthrough) so tests that configure + * `mockAssertFolderMutable`/`mockAssertResourceMutable` to reject with a + * direct vs. inherited `MockResourceLockedError` see the same "unless + * unlocking" behavior the production wrappers implement. + */ +async function assertFolderMutableUnlessUnlocking( + folderId: string | null, + resourceType: string, + unlocking: boolean, + dbClient?: unknown +): Promise { + try { + await mockAssertFolderMutable( + ...[folderId, resourceType, dbClient].filter((a) => a !== undefined) + ) + } catch (error) { + if (unlocking && error instanceof MockResourceLockedError && !error.inherited) return + throw error + } +} + +async function assertResourceMutableUnlessUnlocking( + resourceType: string, + resourceId: string, + unlocking: boolean, + dbClient?: unknown +): Promise { + try { + await mockAssertResourceMutable( + ...[resourceType, resourceId, dbClient].filter((a) => a !== undefined) + ) + } catch (error) { + if (unlocking && error instanceof MockResourceLockedError && !error.inherited) return + throw error + } +} + +export const resourceLockMockFns = { + mockGetFolderLockStatus: vi.fn().mockResolvedValue(unlockedStatus), + mockGetResourceLockStatus: vi.fn().mockResolvedValue(unlockedStatus), + mockAssertFolderMutable, + mockAssertResourceMutable, + mockAssertFolderMutableUnlessUnlocking: vi.fn(assertFolderMutableUnlessUnlocking), + mockAssertResourceMutableUnlessUnlocking: vi.fn(assertResourceMutableUnlessUnlocking), +} + +/** + * Static mock module for `@sim/platform-authz/resource-lock`. + * + * @example + * ```ts + * vi.mock('@sim/platform-authz/resource-lock', () => resourceLockMock) + * ``` + */ +export const resourceLockMock = { + getFolderLockStatus: resourceLockMockFns.mockGetFolderLockStatus, + getResourceLockStatus: resourceLockMockFns.mockGetResourceLockStatus, + assertFolderMutable: resourceLockMockFns.mockAssertFolderMutable, + assertResourceMutable: resourceLockMockFns.mockAssertResourceMutable, + assertFolderMutableUnlessUnlocking: resourceLockMockFns.mockAssertFolderMutableUnlessUnlocking, + assertResourceMutableUnlessUnlocking: + resourceLockMockFns.mockAssertResourceMutableUnlessUnlocking, + ResourceLockedError: MockResourceLockedError, +} diff --git a/packages/testing/src/mocks/schema.mock.ts b/packages/testing/src/mocks/schema.mock.ts index c90f7d3ba06..70a81b05c04 100644 --- a/packages/testing/src/mocks/schema.mock.ts +++ b/packages/testing/src/mocks/schema.mock.ts @@ -68,6 +68,27 @@ export const schemaMock = { updatedAt: 'updatedAt', archivedAt: 'archivedAt', }, + folder: { + id: 'id', + resourceType: 'resourceType', + name: 'name', + userId: 'userId', + workspaceId: 'workspaceId', + parentId: 'parentId', + locked: 'locked', + sortOrder: 'sortOrder', + createdAt: 'createdAt', + updatedAt: 'updatedAt', + deletedAt: 'deletedAt', + }, + pinnedItem: { + id: 'id', + userId: 'userId', + workspaceId: 'workspaceId', + resourceType: 'resourceType', + resourceId: 'resourceId', + pinnedAt: 'pinnedAt', + }, workflow: { id: 'id', userId: 'userId', @@ -530,6 +551,8 @@ export const schemaMock = { originalName: 'originalName', contentType: 'contentType', size: 'size', + folderId: 'folderId', + locked: 'locked', deletedAt: 'deletedAt', uploadedAt: 'uploadedAt', }, @@ -577,6 +600,8 @@ export const schemaMock = { embeddingModel: 'embeddingModel', embeddingDimension: 'embeddingDimension', chunkingConfig: 'chunkingConfig', + folderId: 'folderId', + locked: 'locked', deletedAt: 'deletedAt', createdAt: 'createdAt', updatedAt: 'updatedAt', @@ -1036,6 +1061,8 @@ export const schemaMock = { metadata: 'metadata', maxRows: 'maxRows', rowCount: 'rowCount', + folderId: 'folderId', + locked: 'locked', archivedAt: 'archivedAt', createdBy: 'createdBy', createdAt: 'createdAt', diff --git a/packages/testing/src/mocks/workflows-utils.mock.ts b/packages/testing/src/mocks/workflows-utils.mock.ts index 89613a98907..328502836f1 100644 --- a/packages/testing/src/mocks/workflows-utils.mock.ts +++ b/packages/testing/src/mocks/workflows-utils.mock.ts @@ -24,10 +24,6 @@ export const workflowsUtilsMockFns = { mockUpdateWorkflowRecord: vi.fn(), mockDeleteWorkflowRecord: vi.fn(), mockSetWorkflowVariables: vi.fn(), - mockCreateFolderRecord: vi.fn(), - mockUpdateFolderRecord: vi.fn(), - mockDeleteFolderRecord: vi.fn(), - mockCheckForCircularReference: vi.fn(), mockListFolders: vi.fn(), } @@ -61,9 +57,5 @@ export const workflowsUtilsMock = { updateWorkflowRecord: workflowsUtilsMockFns.mockUpdateWorkflowRecord, deleteWorkflowRecord: workflowsUtilsMockFns.mockDeleteWorkflowRecord, setWorkflowVariables: workflowsUtilsMockFns.mockSetWorkflowVariables, - createFolderRecord: workflowsUtilsMockFns.mockCreateFolderRecord, - updateFolderRecord: workflowsUtilsMockFns.mockUpdateFolderRecord, - deleteFolderRecord: workflowsUtilsMockFns.mockDeleteFolderRecord, - checkForCircularReference: workflowsUtilsMockFns.mockCheckForCircularReference, listFolders: workflowsUtilsMockFns.mockListFolders, }