From 374f2ba4cd7115db52682b5fbc8f1f33c3176654 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 10 Jul 2026 17:49:36 -0700 Subject: [PATCH 01/15] feat(folders): generalize folders and pinning across workflows, files, knowledge bases, and tables Introduces a shared `folder`/`pinned_item` schema replacing the per-resource folder tables, and generalizes resource-level locking (item lock + folder cascade) across all four resource types with full parity to the existing workflow implementation. - Generic `folder` table (resourceType discriminator) + `pinned_item` table, shared API contracts/routes/hooks/UI across workflows, files, knowledge bases, and tables. - Generic resource-lock engine (`@sim/platform-authz/resource-lock`): item-level `locked` column + folder-cascade enforcement, wired into every structural mutation entry point (create, rename, move, delete, restore) for all four resource types, including destination-folder and TOCTOU checks a prior implementation missed. - Migration collapses cleanly with expand/contract discipline: FK adds deferred to a follow-up contract migration (blue/green deploy safety), concurrent index builds, NOT VALID + VALIDATE for new-column FKs, and a dedup-safe backfill for production's pre-existing duplicate folder names. - Extensive review-loop hardening: cycle-detection gaps, archived-row mutability gaps, silent-partial-success batch operations, and lock-check TOCTOU races closed across every restore/move/create entry point, with regression tests for each. # Conflicts: # packages/db/migrations/meta/0259_snapshot.json # packages/db/migrations/meta/_journal.json --- .../api/folders/[id]/duplicate/route.test.ts | 162 + .../app/api/folders/[id]/duplicate/route.ts | 126 +- .../api/folders/[id]/restore/route.test.ts | 119 + .../sim/app/api/folders/[id]/restore/route.ts | 28 +- apps/sim/app/api/folders/[id]/route.test.ts | 66 +- apps/sim/app/api/folders/[id]/route.ts | 46 +- .../sim/app/api/folders/reorder/route.test.ts | 223 +- apps/sim/app/api/folders/reorder/route.ts | 135 +- apps/sim/app/api/folders/route.test.ts | 76 +- apps/sim/app/api/folders/route.ts | 20 +- .../app/api/knowledge/[id]/restore/route.ts | 8 +- apps/sim/app/api/knowledge/[id]/route.ts | 27 + apps/sim/app/api/knowledge/route.ts | 8 + .../[resourceType]/[resourceId]/route.test.ts | 139 + .../[resourceType]/[resourceId]/route.ts | 43 + apps/sim/app/api/pinned-items/route.test.ts | 399 + apps/sim/app/api/pinned-items/route.ts | 218 + .../app/api/table/[tableId]/restore/route.ts | 8 +- apps/sim/app/api/table/[tableId]/route.ts | 40 +- apps/sim/app/api/table/route.ts | 9 + .../api/v1/admin/folders/[id]/export/route.ts | 13 +- apps/sim/app/api/v1/admin/types.ts | 6 +- .../v1/admin/workspaces/[id]/export/route.ts | 8 +- .../v1/admin/workspaces/[id]/folders/route.ts | 20 +- .../v1/admin/workspaces/[id]/import/route.ts | 8 +- .../app/api/v1/admin/workspaces/[id]/route.ts | 8 +- .../[id]/files/[fileId]/restore/route.ts | 7 +- .../workspaces/[id]/files/[fileId]/route.ts | 31 +- .../[id]/files/bulk-archive/route.test.ts | 96 + .../[id]/files/bulk-archive/route.ts | 14 +- .../workspaces/[id]/files/download/route.ts | 21 +- .../files/folders/[folderId]/restore/route.ts | 66 - .../[id]/files/folders/[folderId]/route.ts | 121 - .../workspaces/[id]/files/folders/route.ts | 88 - .../workspaces/[id]/files/move/route.test.ts | 105 + .../api/workspaces/[id]/files/move/route.ts | 16 +- .../workspaces/[id]/files/register/route.ts | 5 + .../app/api/workspaces/[id]/files/route.ts | 5 + apps/sim/app/f/[token]/public-file-view.tsx | 1 + .../components/resource/resource.tsx | 2 +- .../file-row-context-menu.tsx | 16 +- .../mention/use-markdown-mentions.ts | 4 +- .../workspace/[workspaceId]/files/files.tsx | 281 +- .../workspace/[workspaceId]/files/prefetch.ts | 22 +- .../add-resource-dropdown.tsx | 3 +- .../resource-content/resource-content.tsx | 1 + .../resource-registry/resource-registry.tsx | 3 +- .../prompt-editor/use-prompt-editor.test.tsx | 3 - .../workspace/[workspaceId]/home/prefetch.ts | 4 +- .../create-base-modal/create-base-modal.tsx | 4 + .../delete-knowledge-base-modal.tsx | 35 +- .../knowledge/components/index.ts | 1 + .../knowledge-base-context-menu.tsx | 26 +- .../knowledge-folder-context-menu/index.ts | 1 + .../knowledge-folder-context-menu.tsx | 91 + .../knowledge-list-context-menu.tsx | 12 +- .../[workspaceId]/knowledge/knowledge.tsx | 393 +- .../[workspaceId]/knowledge/page.tsx | 13 +- .../[workspaceId]/knowledge/search-params.ts | 19 + .../[workspaceId]/lib/prefetch.test.ts | 40 +- .../app/workspace/[workspaceId]/prefetch.ts | 4 +- .../recently-deleted/recently-deleted.tsx | 95 +- .../table-context-menu/table-context-menu.tsx | 19 +- .../tables-list-context-menu.tsx | 30 +- .../[workspaceId]/tables/search-params.ts | 17 + .../workspace/[workspaceId]/tables/tables.tsx | 521 +- .../panel/components/editor/editor.tsx | 4 +- .../w/[workflowId]/components/panel/panel.tsx | 4 +- .../workflow-search-replace.tsx | 4 +- .../collapsed-sidebar-menu.tsx | 5 +- .../components/file-list/file-list.tsx | 8 +- .../components/folder-item/folder-item.tsx | 66 +- .../workflow-item/workflow-item.tsx | 65 +- .../workflow-list/workflow-list.tsx | 100 +- .../w/hooks/use-duplicate-folder.ts | 1 - .../w/hooks/use-duplicate-selection.ts | 1 - .../w/hooks/use-export-folder.ts | 6 +- .../w/hooks/use-export-selection.ts | 6 +- .../background/cleanup-soft-deletes.test.ts | 2 +- apps/sim/background/cleanup-soft-deletes.ts | 8 +- apps/sim/components/folders/pin-button.tsx | 61 + .../lib/copy/copy-workflows.test.ts | 4 - .../lib/copy/copy-workflows.ts | 14 +- .../lib/mapping/resources.ts | 11 +- apps/sim/hooks/queries/folders.test.ts | 4 - apps/sim/hooks/queries/folders.ts | 168 +- apps/sim/hooks/queries/kb/knowledge.ts | 3 + apps/sim/hooks/queries/pinned-items.test.ts | 203 + apps/sim/hooks/queries/pinned-items.ts | 217 + apps/sim/hooks/queries/tables.ts | 14 +- apps/sim/hooks/queries/utils/folder-cache.ts | 22 +- apps/sim/hooks/queries/utils/folder-keys.ts | 15 +- .../hooks/queries/utils/folder-tree.test.ts | 22 +- apps/sim/hooks/queries/utils/folder-tree.ts | 41 +- .../hooks/queries/workspace-file-folders.ts | 161 +- apps/sim/hooks/queries/workspace-files.ts | 11 +- .../selectors/providers/sim/selectors.ts | 4 +- apps/sim/hooks/use-folder-breadcrumbs.ts | 115 + .../sim/hooks/use-folder-create-with-dedup.ts | 71 + apps/sim/lib/api/contracts/folders.ts | 45 +- apps/sim/lib/api/contracts/index.ts | 1 + apps/sim/lib/api/contracts/knowledge/base.ts | 6 + apps/sim/lib/api/contracts/pinned-items.ts | 79 + apps/sim/lib/api/contracts/tables.ts | 5 + .../api/contracts/workspace-file-folders.ts | 131 +- apps/sim/lib/api/contracts/workspace-files.ts | 2 + apps/sim/lib/cleanup/batch-delete.ts | 11 +- .../copilot/chat/workspace-context.test.ts | 2 +- .../sim/lib/copilot/chat/workspace-context.ts | 10 +- .../tools/handlers/materialize-file.ts | 1 + .../tools/handlers/restore-resource.ts | 11 +- .../tools/handlers/upload-file-reader.ts | 1 + .../lib/copilot/vfs/workflow-alias-backing.ts | 3 +- .../copilot/vfs/workflow-alias-resolver.ts | 18 +- apps/sim/lib/copilot/vfs/workspace-vfs.ts | 36 +- apps/sim/lib/folders/orchestration.ts | 887 + .../sim/lib/folders/parent-validation.test.ts | 120 + apps/sim/lib/folders/parent-validation.ts | 79 + apps/sim/lib/folders/policy.ts | 33 + apps/sim/lib/folders/queries.ts | 21 +- apps/sim/lib/folders/resource-lock.test.ts | 150 + apps/sim/lib/folders/subtree.ts | 45 + apps/sim/lib/folders/tree.ts | 23 +- .../lib/knowledge/orchestration/index.test.ts | 82 + apps/sim/lib/knowledge/orchestration/index.ts | 6 +- apps/sim/lib/knowledge/service.test.ts | 132 + apps/sim/lib/knowledge/service.ts | 158 +- apps/sim/lib/knowledge/types.ts | 5 + apps/sim/lib/logs/folder-expansion.ts | 8 +- .../orchestration/restore-resource.ts | 26 + apps/sim/lib/table/__tests__/service.test.ts | 158 + .../sim/lib/table/orchestration/index.test.ts | 69 + apps/sim/lib/table/orchestration/index.ts | 11 +- apps/sim/lib/table/service.ts | 107 +- apps/sim/lib/table/types.ts | 4 + .../workspace-file-folder-manager.ts | 144 +- .../workspace/workspace-file-manager.ts | 22 +- apps/sim/lib/workflows/lifecycle.ts | 4 +- .../orchestration/folder-lifecycle.ts | 232 +- .../orchestration/workflow-lifecycle.ts | 18 +- .../lib/workflows/persistence/duplicate.ts | 12 +- apps/sim/lib/workflows/utils.ts | 128 +- .../file-folder-lifecycle.test.ts | 265 + .../orchestration/file-folder-lifecycle.ts | 197 +- apps/sim/lib/workspaces/naming.ts | 2 +- apps/sim/stores/folders/types.ts | 11 +- apps/sim/vitest.setup.ts | 2 + packages/db/migrations/0260_smart_wrecker.sql | 150 + .../db/migrations/meta/0260_snapshot.json | 17231 ++++++++++++++++ packages/db/migrations/meta/_journal.json | 7 + packages/db/schema.ts | 121 +- packages/platform-authz/package.json | 4 + packages/platform-authz/src/resource-lock.ts | 219 + packages/platform-authz/src/workflow.ts | 107 +- packages/testing/src/mocks/index.ts | 2 + .../testing/src/mocks/resource-lock.mock.ts | 65 + packages/testing/src/mocks/schema.mock.ts | 21 + .../testing/src/mocks/workflows-utils.mock.ts | 8 - 158 files changed, 25184 insertions(+), 1914 deletions(-) create mode 100644 apps/sim/app/api/folders/[id]/duplicate/route.test.ts create mode 100644 apps/sim/app/api/folders/[id]/restore/route.test.ts create mode 100644 apps/sim/app/api/pinned-items/[resourceType]/[resourceId]/route.test.ts create mode 100644 apps/sim/app/api/pinned-items/[resourceType]/[resourceId]/route.ts create mode 100644 apps/sim/app/api/pinned-items/route.test.ts create mode 100644 apps/sim/app/api/pinned-items/route.ts create mode 100644 apps/sim/app/api/workspaces/[id]/files/bulk-archive/route.test.ts delete mode 100644 apps/sim/app/api/workspaces/[id]/files/folders/[folderId]/restore/route.ts delete mode 100644 apps/sim/app/api/workspaces/[id]/files/folders/[folderId]/route.ts delete mode 100644 apps/sim/app/api/workspaces/[id]/files/folders/route.ts create mode 100644 apps/sim/app/api/workspaces/[id]/files/move/route.test.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/knowledge/components/knowledge-folder-context-menu/index.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/knowledge/components/knowledge-folder-context-menu/knowledge-folder-context-menu.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/knowledge/search-params.ts create mode 100644 apps/sim/components/folders/pin-button.tsx create mode 100644 apps/sim/hooks/queries/pinned-items.test.ts create mode 100644 apps/sim/hooks/queries/pinned-items.ts create mode 100644 apps/sim/hooks/use-folder-breadcrumbs.ts create mode 100644 apps/sim/hooks/use-folder-create-with-dedup.ts create mode 100644 apps/sim/lib/api/contracts/pinned-items.ts create mode 100644 apps/sim/lib/folders/orchestration.ts create mode 100644 apps/sim/lib/folders/parent-validation.test.ts create mode 100644 apps/sim/lib/folders/parent-validation.ts create mode 100644 apps/sim/lib/folders/policy.ts create mode 100644 apps/sim/lib/folders/resource-lock.test.ts create mode 100644 apps/sim/lib/folders/subtree.ts create mode 100644 apps/sim/lib/knowledge/orchestration/index.test.ts create mode 100644 apps/sim/lib/table/__tests__/service.test.ts create mode 100644 apps/sim/lib/table/orchestration/index.test.ts create mode 100644 apps/sim/lib/workspace-files/orchestration/file-folder-lifecycle.test.ts create mode 100644 packages/db/migrations/0260_smart_wrecker.sql create mode 100644 packages/db/migrations/meta/0260_snapshot.json create mode 100644 packages/platform-authz/src/resource-lock.ts create mode 100644 packages/testing/src/mocks/resource-lock.mock.ts 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..531b6a0ff21 --- /dev/null +++ b/apps/sim/app/api/folders/[id]/duplicate/route.test.ts @@ -0,0 +1,162 @@ +/** + * 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 + return { + select: vi.fn().mockReturnValue({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockImplementation(() => { + const row = chain[call] + call += 1 + return row ? [row] : [] + }), + }), + }), + }), + } 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..c038e18c579 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,56 @@ 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) 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)) .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 +298,7 @@ async function assertTargetParentFolderMutable( } currentFolderId = folder.parentId + isFirstIteration = false } } @@ -260,16 +309,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 +346,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 +360,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..80b014e9e32 100644 --- a/apps/sim/app/api/folders/[id]/restore/route.ts +++ b/apps/sim/app/api/folders/[id]/restore/route.ts @@ -1,12 +1,16 @@ +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 { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' const logger = createLogger('RestoreFolderAPI') @@ -30,14 +34,30 @@ 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 }) + // Only 'not_found' gets its own status here -- the other restore-failure + // shapes (validation, and file-folder's name-conflict case which omits + // errorCode) all read naturally as 400, matching this route's prior + // behavior for everything except "not found". + const status = result.errorCode === 'not_found' ? 404 : 400 + return NextResponse.json({ error: result.error }, { status }) } logger.info(`Restored folder ${folderId}`, { restoredItems: result.restoredItems }) @@ -51,6 +71,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..8fe8304b0cd 100644 --- a/apps/sim/app/api/folders/[id]/route.test.ts +++ b/apps/sim/app/api/folders/[id]/route.test.ts @@ -10,14 +10,11 @@ import { type MockUser, permissionsMock, permissionsMockFns, - workflowsOrchestrationMock, 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 +27,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 +51,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 +73,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 +164,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 +178,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 +193,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 +207,36 @@ 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('should update parent folder successfully', async () => { mockAuthenticatedUser() @@ -386,13 +413,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 +434,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 +457,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..0a46dfa2f98 100644 --- a/apps/sim/app/api/folders/[id]/route.ts +++ b/apps/sim/app/api/folders/[id]/route.ts @@ -1,20 +1,20 @@ 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 { 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 { 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 +38,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 +63,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' }, @@ -74,19 +80,18 @@ export const PUT = withRouteHandler( const hasNonLockUpdate = Object.keys(parsed.data.body).some((key) => key !== 'locked') if (hasNonLockUpdate) { - await assertFolderMutable(id) + await policy.assertMutable(id) } 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, @@ -102,7 +107,7 @@ export const PUT = withRouteHandler( 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 +117,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 +127,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 +150,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, @@ -174,7 +178,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..479810106bb 100644 --- a/apps/sim/app/api/folders/reorder/route.test.ts +++ b/apps/sim/app/api/folders/reorder/route.test.ts @@ -3,7 +3,15 @@ * * @vitest-environment node */ -import { authMockFns, createMockRequest, permissionsMock, permissionsMockFns } from '@sim/testing' + +import { ResourceLockedError } from '@sim/platform-authz/resource-lock' +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 +45,14 @@ 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() beforeEach(() => { vi.clearAllMocks() + resourceLockMockFns.mockAssertFolderMutable.mockReset() + resourceLockMockFns.mockAssertFolderMutable.mockResolvedValue(undefined) authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-123' } }) mockGetUserEntityPermissions.mockResolvedValue('admin') @@ -49,17 +61,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' }]), + }), + }), + }) + mockTxSelect.mockReturnValue({ + from: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }), }) 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 +90,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' }]) - .mockReturnValueOnce([{ id: 'foreign', workspaceId: 'workspace-OTHER', archivedAt: null }]) + + 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({ limit: mockLimit }) + mockLimit.mockReturnValueOnce([ + { workspaceId: 'workspace-OTHER', resourceType: 'workflow', deletedAt: null }, + ]) const req = createMockRequest('PUT', { workspaceId: 'workspace-123', @@ -91,6 +158,148 @@ 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(400) + const data = await response.json() + expect(data.error).toBe('One or more folders were not found') + }) + + 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 third assertFolderMutable call is the new + // in-transaction 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' }]) + + resourceLockMockFns.mockAssertFolderMutable + .mockResolvedValueOnce(undefined) + .mockResolvedValueOnce(undefined) + .mockRejectedValueOnce(new ResourceLockedError('workflow', false, 'Folder is locked')) + + 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(resourceLockMockFns.mockAssertFolderMutable).toHaveBeenCalledTimes(3) + expect(mockDb.transaction).toHaveBeenCalledTimes(1) + 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..5a5de210737 100644 --- a/apps/sim/app/api/folders/reorder/route.ts +++ b/apps/sim/app/api/folders/reorder/route.ts @@ -1,14 +1,16 @@ 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 { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' const logger = createLogger('FolderReorderAPI') @@ -37,57 +39,67 @@ 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))) + const validUpdates = updates + + // 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(validUpdates[0].id)! + const hasMixedResourceTypes = validUpdates.some( + (u) => resourceTypeById.get(u.id) !== resourceType ) - - 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) + 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 validUpdates) { + 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) { if (update.parentId !== undefined) { @@ -110,33 +122,32 @@ export const PUT = withRouteHandler(async (req: NextRequest) => { } } + const policy = FOLDER_RESOURCE_POLICIES[resourceType] for (const update of validUpdates) { - await assertFolderMutable(update.id) + 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: validUpdates, }) - 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: 400 } + ) + } + + 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..1e32df7bee3 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 } @@ -99,24 +97,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 +173,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 +197,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 +235,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 +254,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 +276,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 +303,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 +343,7 @@ describe('Folders API Route', () => { mockReturning.mockReturnValueOnce([{ ...mockFolders[0], sortOrder: 1 }]) const req = createMockRequest('POST', { + resourceType: 'workflow', name: 'New Test Folder', workspaceId: 'workspace-123', }) @@ -376,6 +373,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 +392,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 +416,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 +433,7 @@ describe('Folders API Route', () => { mockUnauthenticated() const req = createMockRequest('POST', { + resourceType: 'workflow', name: 'Test Folder', workspaceId: 'workspace-123', }) @@ -450,6 +451,7 @@ describe('Folders API Route', () => { mockGetUserEntityPermissions.mockResolvedValue('read') const req = createMockRequest('POST', { + resourceType: 'workflow', name: 'Test Folder', workspaceId: 'workspace-123', }) @@ -474,6 +476,7 @@ describe('Folders API Route', () => { ) const req = createMockRequest('POST', { + resourceType: 'workflow', name: 'Test Folder', workspaceId: 'workspace-123', }) @@ -498,6 +501,7 @@ describe('Folders API Route', () => { ) const req = createMockRequest('POST', { + resourceType: 'workflow', name: 'Test Folder', workspaceId: 'workspace-123', }) @@ -512,10 +516,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 +544,7 @@ describe('Folders API Route', () => { }) const req = createMockRequest('POST', { + resourceType: 'workflow', name: 'Test Folder', workspaceId: 'workspace-123', }) @@ -575,6 +580,7 @@ describe('Folders API Route', () => { }) const req = createMockRequest('POST', { + resourceType: 'workflow', name: ' Test Folder With Spaces ', workspaceId: 'workspace-123', }) @@ -584,35 +590,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..38897b79c99 100644 --- a/apps/sim/app/api/folders/route.ts +++ b/apps/sim/app/api/folders/route.ts @@ -1,13 +1,14 @@ 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 { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' const logger = createLogger('FoldersAPI') @@ -19,7 +20,6 @@ function folderMutationStatus(errorCode: string | undefined): number { return 500 } -// GET - Fetch folders for a workspace export const GET = withRouteHandler(async (request: NextRequest) => { try { const session = await getSession() @@ -29,9 +29,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 +41,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 +50,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 +61,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,15 +81,15 @@ 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, }) @@ -115,7 +113,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..1876d566e07 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,28 @@ export const PUT = withRouteHandler( const validatedData = parsed.data.body + if (validatedData.locked !== undefined) { + const workspaceId = accessCheck.knowledgeBase.workspaceId + const workspacePermission = workspaceId + ? await getUserEntityPermissions(userId, 'workspace', workspaceId) + : null + 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 +158,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 +168,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 +237,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.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..5ea3f44ec56 --- /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, sql } 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: sql`id` }) + .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..e9a76aa0d32 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' @@ -9,6 +10,7 @@ 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 { 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 +67,7 @@ export const GET = withRouteHandler(async (request: NextRequest, { params }: Tab metadata: table.metadata ?? null, rowCount: table.rowCount, maxRows: maxRowsPerTable, + locked: table.locked, createdAt: table.createdAt instanceof Date ? table.createdAt.toISOString() @@ -125,16 +128,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 Error && error.message.includes('Invalid folderId')) { + return NextResponse.json({ error: error.message }, { status: 400 }) + } logger.error(`[${requestId}] Error renaming table:`, error) return NextResponse.json( @@ -191,6 +226,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..2a1a107ad25 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' @@ -84,6 +85,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { userId: authResult.userId, maxTables: planLimits.maxTables, initialRowCount: params.initialRowCount, + folderId: params.folderId, }, requestId ) @@ -140,6 +142,10 @@ 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 Error) { if (error.message.includes('maximum table limit')) { return NextResponse.json({ error: error.message }, { status: 403 }) @@ -147,6 +153,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { if ( error.message.includes('Invalid table name') || error.message.includes('Invalid schema') || + error.message.includes('Invalid folderId') || error.message.includes('already exists') ) { return NextResponse.json({ error: error.message }, { status: 400 }) @@ -207,6 +214,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..998147022e3 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 { collectDescendantFolderIds } from '@/lib/folders/subtree' import { captureServerEvent } from '@/lib/posthog/server' import { buildWorkspaceFileFolderPathMap, @@ -42,19 +43,15 @@ function withZipPathSuffix(path: string, suffix: number): string { : `${directory}${filename} (${suffix})` } -function collectDescendantFolderIds( - selectedFolderIds: string[], +/** Unions each root folder id with every descendant reachable from it. */ +function collectSelectedFolderIds( + rootIds: 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 - } + const folderIds = new Set(rootIds) + for (const rootId of rootIds) { + for (const descendantId of collectDescendantFolderIds(folders, rootId)) { + folderIds.add(descendantId) } } return folderIds @@ -83,7 +80,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..5f5f8ec3990 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,10 +592,13 @@ 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 + // 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) lastSelectedIndexRef.current = -1 const visible = new Set(visibleRowIds) setSelectedRowIds((prev) => { @@ -527,7 +606,7 @@ export function Files() { const next = new Set(Array.from(prev).filter((id) => visible.has(id))) return next.size === prev.size ? prev : next }) - }, [visibleRowIds]) + } const isAllSelected = visibleRowIds.length > 0 && visibleRowIds.every((id) => selectedRowIds.has(id)) @@ -1172,32 +1251,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 +1277,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 +1354,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 +1688,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 +2056,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..2f99e988c9d 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,24 @@ export function Knowledge() { [deleteKnowledgeBaseMutation] ) + 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 = debouncedSearchQuery + ? siblings.filter((folder) => + folder.name.toLowerCase().includes(debouncedSearchQuery.toLowerCase()) + ) + : siblings + return [...searched].sort((a, b) => a.name.localeCompare(b.name)) + }, [folders, currentFolderId, debouncedSearchQuery]) + 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 +389,7 @@ export function Knowledge() { }) }, [ knowledgeBases, + currentFolderId, debouncedSearchQuery, connectorFilter, contentFilter, @@ -292,7 +398,7 @@ export function Knowledge() { members, ]) - const rows: ResourceRow[] = useMemo( + const baseRows: ResourceRow[] = useMemo( () => processedKBs.map((kb) => { const kbWithCount = kb as KnowledgeBaseWithDocCount @@ -300,8 +406,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 +444,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 +591,99 @@ export function Knowledge() { setIsDeleteModalOpen(true) }, []) - const canEdit = userPermissions.canEdit === true + const knowledgeBaseInheritedLocked = isFolderOrAncestorLocked( + activeKnowledgeBase?.folderId ?? null, + Object.fromEntries(folderById) + ) + + 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, + Object.fromEntries(folderById) + ) + + 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 +692,7 @@ export function Knowledge() { variant: 'primary', }, ], - [handleOpenCreateModal, canEdit] + [handleCreateFolder, createFolder.isPending, handleOpenCreateModal, canEdit] ) const searchConfig: SearchConfig = useMemo( @@ -555,10 +850,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 +921,26 @@ export function Knowledge() { showDelete disableEdit={!canEdit} disableDelete={!canEdit} + onToggleLock={handleToggleKnowledgeBaseLock} + showLock + disableLock={!userPermissions.canAdmin || knowledgeBaseInheritedLocked} + isLocked={Boolean(activeKnowledgeBase.locked)} + /> + )} + + {activeFolder && ( + )} @@ -622,6 +966,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..751c553c327 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,23 @@ export function RecentlyDeleted() { }) break case 'workspace_folder': - await restoreWorkspaceFileFolder.mutateAsync({ + await restoreFolder.mutateAsync({ + workspaceId: resource.workspaceId, + folderId: resource.id, + }) + 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..35f1e62d700 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,24 @@ 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) + }, + // eslint-disable-next-line react-hooks/exhaustive-deps -- listRename.startRename is stable + [] + ) + + const handleCreateFolder = useFolderCreateWithDedup({ + workspaceId, + resourceType: 'table', + folders, + currentFolderId, + createFolder, + onCreated: handleFolderCreated, + }) const headerActions: ResourceAction[] = useMemo( () => [ @@ -603,36 +882,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 +978,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 +1034,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..ad03927296b 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,8 +137,11 @@ async function cleanupWorkspaceFileStorage( const CLEANUP_TARGETS = [ { table: workflowFolder, - softDeleteCol: workflowFolder.archivedAt, + softDeleteCol: workflowFolder.deletedAt, wsCol: workflowFolder.workspaceId, + // `folder` is now a polymorphic table shared with file/knowledge_base/table + // folders — scope hard-delete to workflow folders only. + extraPredicate: eq(workflowFolder.resourceType, 'workflow'), name: 'workflowFolder', }, { @@ -339,6 +342,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..c1dba3b239d 100644 --- a/apps/sim/hooks/queries/folders.ts +++ b/apps/sim/hooks/queries/folders.ts @@ -7,6 +7,7 @@ import { deleteFolderContract, duplicateFolderContract, type FolderApi, + type FolderResourceType, listFoldersContract, reorderFoldersContract, restoreFolderContract, @@ -21,65 +22,74 @@ import { } from '@/hooks/queries/utils/optimistic-mutation' 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 type { Folder } from '@/stores/folders/types' 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 +99,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 +194,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 +210,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 +249,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 + ), + }) }, }) } @@ -235,6 +275,7 @@ export function useDeleteFolderMutation() { interface RestoreFolderVariables { workspaceId: string + resourceType?: FolderResourceType folderId: string } @@ -270,12 +311,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 +325,7 @@ export function useDuplicateFolderMutation() { ), createdAt: new Date(), updatedAt: new Date(), - archivedAt: null, + deletedAt: null, } }, (variables) => variables.newId ?? generateId() @@ -297,16 +337,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 +352,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 +365,7 @@ export function useDuplicateFolderMutation() { interface ReorderFoldersVariables { workspaceId: string + resourceType?: FolderResourceType updates: Array<{ id: string sortOrder: number @@ -334,17 +378,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 +404,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..cb155b3acbb --- /dev/null +++ b/apps/sim/hooks/queries/pinned-items.ts @@ -0,0 +1,217 @@ +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 +} + +/** + * 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[3] + 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..776c0e89f92 --- /dev/null +++ b/apps/sim/hooks/use-folder-create-with-dedup.ts @@ -0,0 +1,71 @@ +import { useCallback } from 'react' +import { toast } from '@sim/emcn' +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import type { useCreateFolder } from '@/hooks/queries/folders' +import type { Folder, FolderResourceType } 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..ebb3a9e7a6d 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) @@ -2241,11 +2241,15 @@ export class WorkspaceVFS { .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' }), diff --git a/apps/sim/lib/folders/orchestration.ts b/apps/sim/lib/folders/orchestration.ts new file mode 100644 index 00000000000..d3a70706414 --- /dev/null +++ b/apps/sim/lib/folders/orchestration.ts @@ -0,0 +1,887 @@ +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, 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 } 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 reserved-but-unused for `file` folders (see + * `packages/db/schema.ts`), so we mirror the table's own default here + * rather than round-tripping to re-select it. + */ +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) { + if ( + error instanceof WorkspaceFileFolderConflictError || + getPostgresErrorCode(error) === '23505' + ) { + return { success: false, error: toError(error).message, errorCode: 'conflict' } + } + 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) { + if ( + error instanceof WorkspaceFileFolderConflictError || + getPostgresErrorCode(error) === '23505' + ) { + return { success: false, error: toError(error).message, errorCode: 'conflict' } + } + if (toError(error).message === 'Folder not found') { + return { success: false, error: 'Folder not found', errorCode: 'not_found' } + } + 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) { + 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', + } + } + const message = toError(error).message + if (message === 'Folder not found' || message === 'Folder is not archived') { + return { success: false, error: message } + } + logger.error('Failed to restore file folder', { error }) + return { success: false, error: message } + } +} + +/** + * 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 + + const folderIds = await collectFolderSubtreeIds(workspaceId, cascade.resourceType, folderId, tx) + + 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) => { + const [raw] = await tx + .select() + .from(folderTable) + .where( + and( + eq(folderTable.id, folderId), + eq(folderTable.workspaceId, workspaceId), + eq(folderTable.resourceType, cascade.resourceType) + ) + ) + .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) { + const [parent] = await tx + .select({ deletedAt: folderTable.deletedAt }) + .from(folderTable) + .where(eq(folderTable.id, resolvedParentId)) + .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 } +} + +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 [created] = await db + .insert(folderTable) + .values({ + id: folderId, + resourceType: params.resourceType, + name: params.name.trim(), + userId: params.userId, + workspaceId: params.workspaceId, + parentId, + sortOrder: params.sortOrder ?? 0, + }) + .returning() + + return { success: true, folder: created } +} + +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 [updated] = await db + .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() + + if (!updated) return { success: false, error: 'Folder not found', errorCode: 'not_found' } + return { success: true, folder: updated } +} + +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) +} + +export async function performReorderFolders( + params: PerformReorderFoldersParams +): Promise<{ success: boolean; updated: number; error?: string }> { + 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' } + } + const validUpdates = updates + + // 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(validUpdates.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 } + } + + try { + await db.transaction(async (tx) => { + // Re-check each target parent is still active inside the transaction -- + // assertFolderParentValid above only reads at validation time, so a parent + // concurrently soft-deleted before this transaction opens could otherwise + // leave an active folder pointing at a deleted one. + if (targetParentIds.length > 0) { + const activeParents = await tx + .select({ id: folderTable.id }) + .from(folderTable) + .where(and(inArray(folderTable.id, targetParentIds), isNull(folderTable.deletedAt))) + if (activeParents.length !== targetParentIds.length) { + throw new Error('Parent folder not found') + } + } + + // 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 + // both inside the transaction (joining `tx` so the read is part of the + // same atomic unit as the writes below) before applying anything. + for (const update of validUpdates) { + await assertFolderMutable(update.id, resourceType, tx) + } + for (const parentId of targetParentIds) { + await assertFolderMutable(parentId, resourceType, tx) + } + + for (const update of validUpdates) { + 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 Error('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 + } + return { success: false, updated: 0, error: toError(error).message } + } + + return { success: true, updated: validUpdates.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..75687120f0a --- /dev/null +++ b/apps/sim/lib/folders/resource-lock.test.ts @@ -0,0 +1,150 @@ +/** + * @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, + assertResourceMutable, + 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) + }) + }) +}) + +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..bc17025a48c 100644 --- a/apps/sim/lib/knowledge/service.test.ts +++ b/apps/sim/lib/knowledge/service.test.ts @@ -10,8 +10,36 @@ 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, 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 + } + } + return { + mockAssertResourceMutable: vi.fn(), + mockAssertFolderMutable: vi.fn(), + 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, + ResourceLockedError: MockResourceLockedError, +})) import { KnowledgeBasePermissionError, updateKnowledgeBase } from '@/lib/knowledge/service' @@ -200,3 +228,107 @@ 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('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..87bf310a858 100644 --- a/apps/sim/lib/knowledge/service.ts +++ b/apps/sim/lib/knowledge/service.ts @@ -8,10 +8,12 @@ import { workspaceFiles, } from '@sim/db/schema' import { createLogger } from '@sim/logger' +import { assertFolderMutable, assertResourceMutable } 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 +34,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 +69,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,37 +172,58 @@ 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) + } + // assertFolderParentValid above only checks workspace/type/deleted-state -- without + // this, a knowledge base could be created directly inside a locked folder. + if (data.folderId) { + await assertFolderMutable(data.folderId, 'knowledge_base') + } + 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) - } - try { await db.insert(knowledgeBase).values(newKnowledgeBase) } catch (error: unknown) { @@ -219,6 +248,8 @@ export async function createKnowledgeBase( updatedAt: now, deletedAt: null, workspaceId: data.workspaceId, + folderId: data.folderId ?? null, + locked: false, docCount: 0, connectorTypes: [], } @@ -233,6 +264,8 @@ export async function updateKnowledgeBase( name?: string description?: string workspaceId?: string | null + folderId?: string | null + locked?: boolean chunkingConfig?: { maxSize: number minSize: number @@ -248,6 +281,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 +297,26 @@ 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 + if (hasNonLockUpdate) { + await assertResourceMutable('knowledge_base', knowledgeBaseId) + } + if (updates.workspaceId !== undefined && !options?.actorUserId) { throw new KnowledgeBasePermissionError( 'actorUserId is required to change a knowledge base workspace' @@ -319,28 +370,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 +483,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 +535,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,6 +572,8 @@ export async function deleteKnowledgeBase( knowledgeBaseId: string, requestId: string ): Promise { + await assertResourceMutable('knowledge_base', knowledgeBaseId) + const now = new Date() await db.transaction(async (tx) => { @@ -587,6 +669,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..4d0b607ad32 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,9 @@ 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), 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..08661e07fea --- /dev/null +++ b/apps/sim/lib/table/__tests__/service.test.ts @@ -0,0 +1,158 @@ +/** + * @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(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(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('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(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..a1034c167ef 100644 --- a/apps/sim/lib/table/service.ts +++ b/apps/sim/lib/table/service.ts @@ -11,11 +11,13 @@ 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 } 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' @@ -128,6 +130,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 +160,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 +190,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 +228,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 +280,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 +296,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 Error(`Invalid folderId: ${parentError.error}`) } // Wrap count check, duplicate check, and insert in a transaction with FOR UPDATE @@ -302,6 +326,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 +422,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,30 +558,72 @@ 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(', ')) } + if (!isLockOnlyUpdate) { + await assertResourceMutable('table', tableId) + } + + if (folderId) { + const [tableRow] = await db + .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', + }) + if (parentError) { + throw new Error(`Invalid folderId: ${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') + } + const now = new Date() try { const result = await db .update(userTableDefinitions) - .set({ name: newName, updatedAt: now }) + .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 (result.length === 0) { throw new Error(`Table ${tableId} not found`) } - const { createdBy, workspaceId } = result[0] + const { createdBy, workspaceId, folderId: updatedFolderId, locked: updatedLocked } = result[0] const renameActorId = actingUserId ?? createdBy if (renameActorId) { recordAudit({ @@ -564,7 +639,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,6 +724,8 @@ export async function deleteTable( requestId: string, actingUserId?: string ): Promise { + await assertResourceMutable('table', tableId) + const now = new Date() const result = await db .update(userTableDefinitions) @@ -713,6 +790,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..73fb8df187b 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,16 @@ 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, + 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 +60,7 @@ export interface WorkspaceFileFolderRecord { parentId: string | null path: string sortOrder: number + locked: boolean deletedAt: Date | null createdAt: Date updatedAt: Date @@ -66,6 +73,7 @@ interface RawWorkspaceFileFolder { name: string parentId: string | null sortOrder: number + locked: boolean deletedAt: Date | null createdAt: Date updatedAt: Date @@ -164,6 +172,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 +192,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 +218,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 +307,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 +350,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 +376,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 +389,7 @@ export async function createWorkspaceFileFolder(params: { name: string parentId?: string | null sortOrder?: number + id?: string }): Promise { const name = normalizeWorkspaceFileItemName(params.name, 'Folder') @@ -380,6 +405,7 @@ export async function createWorkspaceFileFolder(params: { and( eq(workspaceFileFolder.id, parentId), eq(workspaceFileFolder.workspaceId, params.workspaceId), + eq(workspaceFileFolder.resourceType, 'file'), isNull(workspaceFileFolder.deletedAt) ) ) @@ -396,6 +422,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 +440,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 +489,7 @@ export async function ensureWorkspaceFileFolderPath(params: { .where( and( eq(workspaceFileFolder.workspaceId, params.workspaceId), + eq(workspaceFileFolder.resourceType, 'file'), isNull(workspaceFileFolder.deletedAt) ) ) @@ -498,6 +528,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 +562,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 +580,7 @@ export async function updateWorkspaceFileFolder(params: { and( eq(workspaceFileFolder.id, params.folderId), eq(workspaceFileFolder.workspaceId, params.workspaceId), + eq(workspaceFileFolder.resourceType, 'file'), isNull(workspaceFileFolder.deletedAt) ) ) @@ -601,6 +606,7 @@ export async function updateWorkspaceFileFolder(params: { and( eq(workspaceFileFolder.id, finalParentId), eq(workspaceFileFolder.workspaceId, params.workspaceId), + eq(workspaceFileFolder.resourceType, 'file'), isNull(workspaceFileFolder.deletedAt) ) ) @@ -618,6 +624,7 @@ export async function updateWorkspaceFileFolder(params: { .where( and( eq(workspaceFileFolder.workspaceId, params.workspaceId), + eq(workspaceFileFolder.resourceType, 'file'), isNull(workspaceFileFolder.deletedAt) ) ) @@ -635,6 +642,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 +667,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 +679,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 +742,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 +764,7 @@ export async function moveWorkspaceFileItems(params: { .where( and( eq(workspaceFileFolder.workspaceId, params.workspaceId), + eq(workspaceFileFolder.resourceType, 'file'), isNull(workspaceFileFolder.deletedAt) ) ) @@ -786,6 +801,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 +817,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 +860,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 +904,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 +931,7 @@ export async function archiveWorkspaceFileFolderRecursive( and( eq(workspaceFileFolder.id, folderId), eq(workspaceFileFolder.workspaceId, workspaceId), + eq(workspaceFileFolder.resourceType, 'file'), isNull(workspaceFileFolder.deletedAt) ) ) @@ -910,7 +943,11 @@ export async function archiveWorkspaceFileFolderRecursive( .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 +971,7 @@ export async function archiveWorkspaceFileFolderRecursive( and( inArray(workspaceFileFolder.id, folderIds), eq(workspaceFileFolder.workspaceId, workspaceId), + eq(workspaceFileFolder.resourceType, 'file'), isNull(workspaceFileFolder.deletedAt) ) ) @@ -966,7 +1004,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 +1016,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 +1034,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 +1043,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 +1077,7 @@ export async function restoreWorkspaceFileFolder( and( eq(workspaceFileFolder.parentId, currentFolderId), eq(workspaceFileFolder.workspaceId, workspaceId), + eq(workspaceFileFolder.resourceType, 'file'), eq(workspaceFileFolder.deletedAt, folderDeletedAt) ) ) @@ -1033,6 +1090,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 +1106,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 +1126,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 { @@ -1093,6 +1159,7 @@ export async function bulkArchiveWorkspaceFileItems(params: { .where( and( eq(workspaceFileFolder.workspaceId, params.workspaceId), + eq(workspaceFileFolder.resourceType, 'file'), isNull(workspaceFileFolder.deletedAt) ) ) @@ -1143,6 +1210,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..75131a05040 100644 --- a/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts +++ b/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts @@ -68,6 +68,7 @@ export interface WorkspaceFileRecord { uploadedBy: string folderId?: string | null folderPath?: string | null + locked: boolean deletedAt?: Date | null uploadedAt: Date updatedAt: Date @@ -580,6 +581,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 +965,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,20 +978,26 @@ 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) + } } let updated: { id: string }[] try { updated = await db .update(workspaceFiles) - .set({ originalName: normalizedName, updatedAt: new Date() }) + .set({ + originalName: normalizedName, + updatedAt: new Date(), + ...(locked !== undefined ? { locked } : {}), + }) .where( and( eq(workspaceFiles.id, fileId), @@ -1013,6 +1022,7 @@ export async function renameWorkspaceFile( return { ...fileRecord, name: normalizedName, + locked: locked !== undefined ? locked : fileRecord.locked, } } 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..1bbe0866cab 100644 --- a/apps/sim/lib/workflows/orchestration/folder-lifecycle.ts +++ b/apps/sim/lib/workflows/orchestration/folder-lifecycle.ts @@ -1,22 +1,23 @@ 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, 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') + export interface PerformCreateFolderParams { userId: string workspaceId: string @@ -31,7 +32,7 @@ export interface PerformCreateFolderResult { success: boolean error?: string errorCode?: OrchestrationErrorCode - folder?: typeof workflowFolder.$inferSelect + folder?: typeof folder.$inferSelect } export interface PerformUpdateFolderParams { @@ -39,8 +40,6 @@ export interface PerformUpdateFolderParams { workspaceId: string userId: string name?: string - color?: string - isExpanded?: boolean locked?: boolean parentId?: string | null sortOrder?: number @@ -50,52 +49,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 +98,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,15 +110,15 @@ export async function performCreateFolder( ? params.sortOrder : await nextFolderSortOrder(params.workspaceId, parentId) - const [folder] = await db - .insert(workflowFolder) + const [createdFolder] = await db + .insert(folder) .values({ id: folderId, + resourceType: 'workflow', name: params.name.trim(), userId: params.userId, workspaceId: params.workspaceId, parentId, - color: params.color || '#6B7280', sortOrder, }) .returning() @@ -158,19 +131,25 @@ 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) { + 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 +164,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 +182,38 @@ 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) + const [updatedFolder] = await db + .update(folder) .set(updates) .where( and( - eq(workflowFolder.id, params.folderId), - eq(workflowFolder.workspaceId, params.workspaceId) + eq(folder.id, params.folderId), + eq(folder.workspaceId, params.workspaceId), + isWorkflowFolder, + isNull(folder.deletedAt) ) ) .returning() - 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) { + 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 +221,26 @@ 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 } 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 +270,7 @@ async function deleteFolderRecursively( stats.workflows += workflowsInFolder.length } - await db - .update(workflowFolder) - .set({ archivedAt: timestamp }) - .where(eq(workflowFolder.id, folderId)) + await db.update(folder).set({ deletedAt: timestamp }).where(eq(folder.id, folderId)) stats.folders += 1 return stats @@ -312,13 +299,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 +383,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 +404,7 @@ async function restoreFolderRecursively( and( eq(workflow.folderId, folderId), eq(workflow.workspaceId, workspaceId), - eq(workflow.archivedAt, folderArchivedAt) + eq(workflow.archivedAt, folderDeletedAt) ) ) @@ -441,18 +429,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,31 +461,38 @@ 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 + const [existingFolder] = await db .select() - .from(workflowFolder) - .where(and(eq(workflowFolder.id, folderId), eq(workflowFolder.workspaceId, workspaceId))) + .from(folder) + .where(and(eq(folder.id, folderId), eq(folder.workspaceId, workspaceId), isWorkflowFolder)) - if (!folder) { - return { success: false, error: 'Folder not found' } + if (!existingFolder) { + return { success: false, error: 'Folder not found', errorCode: 'not_found' } } - if (!folder.archivedAt) { + if (!existingFolder.deletedAt) { return { success: true, restoredItems: { folders: 0, workflows: 0 } } } + // 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 (existingFolder.locked) { + throw new ResourceLockedError('workflow', false, 'Folder is locked') + } + const { getWorkspaceWithOwner } = await import('@/lib/workspaces/permissions/utils') const ws = await getWorkspaceWithOwner(workspaceId) if (!ws || ws.archivedAt) { @@ -504,21 +500,27 @@ export async function performRestoreFolder( } const restoredStats = await db.transaction(async (tx) => { - if (folder.parentId) { + let resolvedParentId = existingFolder.parentId + if (resolvedParentId) { 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)) + + 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) + + return restoreFolderRecursively(folderId, workspaceId, existingFolder.deletedAt!, tx) }) logger.info('Restored folder and all contents:', { folderId, restoredStats }) @@ -529,8 +531,8 @@ 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 ?? existingFolder.name, + description: `Restored folder "${folderName ?? existingFolder.name}"`, metadata: { affected: { workflows: restoredStats.workflows, diff --git a/apps/sim/lib/workflows/orchestration/workflow-lifecycle.ts b/apps/sim/lib/workflows/orchestration/workflow-lifecycle.ts index 15917517fd4..0c22368d614 100644 --- a/apps/sim/lib/workflows/orchestration/workflow-lifecycle.ts +++ b/apps/sim/lib/workflows/orchestration/workflow-lifecycle.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, workflow } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { isFolderInWorkspace } from '@sim/platform-authz/workflow' import { toError } from '@sim/utils/errors' @@ -116,9 +116,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 +130,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< diff --git a/apps/sim/lib/workflows/persistence/duplicate.ts b/apps/sim/lib/workflows/persistence/duplicate.ts index ab075a8be5f..936428263ea 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' @@ -77,7 +77,7 @@ async function assertTargetFolderMutable( parentId: workflowFolder.parentId, workspaceId: workflowFolder.workspaceId, locked: workflowFolder.locked, - archivedAt: workflowFolder.archivedAt, + archivedAt: workflowFolder.deletedAt, }) .from(workflowFolder) .where(eq(workflowFolder.id, currentFolderId)) @@ -193,7 +193,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..fb52fc57e63 --- /dev/null +++ b/apps/sim/lib/workspace-files/orchestration/file-folder-lifecycle.test.ts @@ -0,0 +1,265 @@ +/** + * @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() + }) + }) + + 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..835772588a2 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,23 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' +import { + assertFolderMutable, + assertResourceMutable, + 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 +32,7 @@ export type WorkspaceFilesOrchestrationErrorCode = | 'validation' | 'not_found' | 'conflict' + | 'locked' | 'internal' export function workspaceFilesOrchestrationStatus( @@ -32,6 +41,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 +79,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 +161,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 +211,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 +233,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 +282,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 +310,14 @@ 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) + if (!isLockOnlyUpdate) { + await assertResourceMutable('file', fileId) + } + + const file = await renameWorkspaceFile(workspaceId, fileId, name, locked) logger.info('Renamed workspace file', { workspaceId, fileId, name: file.name }) @@ -299,6 +333,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 +350,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 +367,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 +378,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: 'not_found' } + } - 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..e4073c6a472 100644 --- a/apps/sim/stores/folders/types.ts +++ b/apps/sim/stores/folders/types.ts @@ -1,19 +1,20 @@ -export interface WorkflowFolder { +export type FolderResourceType = 'workflow' | 'file' | 'knowledge_base' | 'table' + +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..5072244e6ae 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 meaningful for workflow folders only today (lock-cascade feature); kept + // as a reserved-but-unused column for file/knowledge_base/table folders rather than + // splitting into per-resource tables. `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..7620fd9f18a --- /dev/null +++ b/packages/platform-authz/src/resource-lock.ts @@ -0,0 +1,219 @@ +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() + + while (currentFolderId && !visited.has(currentFolderId)) { + visited.add(currentFolderId) + 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) + ) + ) + .limit(1) + + if (!folderRow) break + if (folderRow.locked) { + return { + locked: true, + directLocked: isDirect, + inheritedLocked: !isDirect, + lockedBy: 'folder', + lockedFolderId: folderRow.id, + } + } + + currentFolderId = folderRow.parentId + isDirect = false + } + + return 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] + + const [row] = await dbClient + .select({ + locked: config.lockedColumn, + folderId: config.folderIdColumn, + }) + .from(config.table) + .where(eq(config.idColumn, resourceId)) + .limit(1) + + if (!row) return UNLOCKED_STATUS + + if (row.locked) { + return { + locked: true, + directLocked: true, + inheritedLocked: false, + lockedBy: 'resource', + lockedFolderId: null, + } + } + + return getFolderLockStatus(row.folderId as string | null, resourceType, dbClient) +} + +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` + ) + } +} diff --git a/packages/platform-authz/src/workflow.ts b/packages/platform-authz/src/workflow.ts index c1d643a5850..b650239448a 100644 --- a/packages/platform-authz/src/workflow.ts +++ b/packages/platform-authz/src/workflow.ts @@ -1,5 +1,9 @@ -import { db, workflow, workflowFolder, workspace } from '@sim/db' +import { db, folder, workflow, workspace } from '@sim/db' import { and, eq, isNull } from 'drizzle-orm' +import { + getFolderLockStatus as getGenericFolderLockStatus, + ResourceLockedError, +} from './resource-lock' import { type PermissionType, permissionSatisfies, @@ -62,20 +66,22 @@ export async function assertActiveWorkflowContext( type WorkflowRecord = typeof workflow.$inferSelect -export class WorkflowLockedError extends Error { - readonly status = 423 - +/** + * 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') { - super(message) + super('workflow', false, 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 +94,29 @@ 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 - } - +function toWorkflowLockStatus( + status: Awaited> +): 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({ @@ -187,7 +165,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..13b25b5d8e3 --- /dev/null +++ b/packages/testing/src/mocks/resource-lock.mock.ts @@ -0,0 +1,65 @@ +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) + * ) + * ``` + */ +export const resourceLockMockFns = { + mockGetFolderLockStatus: vi.fn().mockResolvedValue(unlockedStatus), + mockGetResourceLockStatus: vi.fn().mockResolvedValue(unlockedStatus), + mockAssertFolderMutable: vi.fn().mockResolvedValue(undefined), + mockAssertResourceMutable: vi.fn().mockResolvedValue(undefined), +} + +/** + * 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, + ResourceLockedError: MockResourceLockedError, +} diff --git a/packages/testing/src/mocks/schema.mock.ts b/packages/testing/src/mocks/schema.mock.ts index c90f7d3ba06..29ae4d249df 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', 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, } From cdd5045f1f868b9f690b8d8ea3b85e3214cf287e Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 10 Jul 2026 17:59:47 -0700 Subject: [PATCH 02/15] fix(folders): map reorder internal errors to 500 instead of 400 performReorderFolders caught every transaction failure -- including genuine unexpected DB/transaction errors, not just client-caused validation issues -- and returned success:false with no way to distinguish them. The route then unconditionally mapped any failure to 400, mislabeling real server errors as bad requests. Adds errorCode: OrchestrationErrorCode to the result (not_found for concurrent-deletion races and missing parents, internal for anything else, following the same pattern already used elsewhere in this file), and the route now maps status via the shared statusForOrchestrationError helper. Unexpected errors are logged with their real cause but returned to the client as a generic message instead of leaking internals. --- .../sim/app/api/folders/reorder/route.test.ts | 31 ++++++++++++- apps/sim/app/api/folders/reorder/route.ts | 3 +- apps/sim/lib/folders/orchestration.ts | 43 +++++++++++++++---- 3 files changed, 67 insertions(+), 10 deletions(-) diff --git a/apps/sim/app/api/folders/reorder/route.test.ts b/apps/sim/app/api/folders/reorder/route.test.ts index 479810106bb..16c140ff472 100644 --- a/apps/sim/app/api/folders/reorder/route.test.ts +++ b/apps/sim/app/api/folders/reorder/route.test.ts @@ -262,11 +262,40 @@ describe('PUT /api/folders/reorder', () => { const response = await PUT(req) - expect(response.status).toBe(400) + 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 diff --git a/apps/sim/app/api/folders/reorder/route.ts b/apps/sim/app/api/folders/reorder/route.ts index 5a5de210737..3776aa44a7a 100644 --- a/apps/sim/app/api/folders/reorder/route.ts +++ b/apps/sim/app/api/folders/reorder/route.ts @@ -11,6 +11,7 @@ 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') @@ -139,7 +140,7 @@ export const PUT = withRouteHandler(async (req: NextRequest) => { if (!result.success) { return NextResponse.json( { error: result.error ?? 'No valid folders to update' }, - { status: 400 } + { status: statusForOrchestrationError(result.errorCode) } ) } diff --git a/apps/sim/lib/folders/orchestration.ts b/apps/sim/lib/folders/orchestration.ts index d3a70706414..016efead33f 100644 --- a/apps/sim/lib/folders/orchestration.ts +++ b/apps/sim/lib/folders/orchestration.ts @@ -777,9 +777,15 @@ export async function performRestoreFolder( return performRestoreResourceFolder(params, TABLE_FOLDER_CASCADE) } -export async function performReorderFolders( - params: PerformReorderFoldersParams -): Promise<{ success: boolean; updated: number; error?: string }> { +/** Marks a concurrent-deletion race caught inside the reorder transaction as a 404, not a 500. */ +class FolderReorderNotFoundError 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) @@ -803,7 +809,12 @@ export async function performReorderFolders( // 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' } + return { + success: false, + updated: 0, + error: 'One or more folders were not found', + errorCode: 'not_found', + } } const validUpdates = updates @@ -822,7 +833,12 @@ export async function performReorderFolders( ) const firstParentError = parentErrors.find((error) => error !== null) if (firstParentError) { - return { success: false, updated: 0, error: firstParentError.error } + return { + success: false, + updated: 0, + error: firstParentError.error, + errorCode: firstParentError.errorCode, + } } try { @@ -837,7 +853,7 @@ export async function performReorderFolders( .from(folderTable) .where(and(inArray(folderTable.id, targetParentIds), isNull(folderTable.deletedAt))) if (activeParents.length !== targetParentIds.length) { - throw new Error('Parent folder not found') + throw new FolderReorderNotFoundError('Parent folder not found') } } @@ -871,7 +887,7 @@ export async function performReorderFolders( .where(and(eq(folderTable.id, update.id), isNull(folderTable.deletedAt))) .returning({ id: folderTable.id }) if (!updated) { - throw new Error('One or more folders were not found') + throw new FolderReorderNotFoundError('One or more folders were not found') } } }) @@ -880,7 +896,18 @@ export async function performReorderFolders( if (error instanceof ResourceLockedError) { throw error } - return { success: false, updated: 0, error: toError(error).message } + if (error instanceof FolderReorderNotFoundError) { + return { success: false, updated: 0, error: error.message, errorCode: 'not_found' } + } + // 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: validUpdates.length } From 9df6dff432e83af62fc9a0064474b16910f700eb Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 10 Jul 2026 18:12:17 -0700 Subject: [PATCH 03/15] fix(folders): close workflow-folder restore lock race, align restore error codes Three related restore-path bugs found by Greptile/Cursor this round: 1. performRestoreFolder (workflow folders) read the folder's own `locked` flag BEFORE opening the restore transaction. A folder locked in the window between that read and the transaction's write could still be resurrected and mutated. Restructured to match performRestoreResourceFolder (kb/table): the not_found/not_archived/locked checks and the restore writes all now happen inside one transaction. 2. Restoring an already-active (non-archived) workflow folder silently returned 200/success with zero restoredItems, while the same request for knowledge base, table, and file folders correctly returns a 400 validation error. Now returns errorCode: 'validation' -> 400, consistent with the other three resource types. 3. performRestoreFileFolder's 'Folder not found' case returned no errorCode, so it fell through the route's default and mapped to 400 instead of 404 (workflow/kb/table restores already mapped this correctly). Added errorCode: 'not_found' to that branch, plus errorCode: 'conflict'/'validation'/'internal' to the sibling branches for consistency with performCreateFileFolder's existing pattern, and stopped leaking the raw internal error message to the client on an unexpected failure. --- apps/sim/lib/folders/orchestration.ts | 10 ++- .../orchestration/folder-lifecycle.ts | 67 +++++++++++-------- 2 files changed, 47 insertions(+), 30 deletions(-) diff --git a/apps/sim/lib/folders/orchestration.ts b/apps/sim/lib/folders/orchestration.ts index 016efead33f..71588f970a0 100644 --- a/apps/sim/lib/folders/orchestration.ts +++ b/apps/sim/lib/folders/orchestration.ts @@ -311,14 +311,18 @@ async function performRestoreFileFolder( 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' || message === 'Folder is not archived') { - return { success: false, 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' } } logger.error('Failed to restore file folder', { error }) - return { success: false, error: message } + return { success: false, error: 'Internal server error', errorCode: 'internal' } } } diff --git a/apps/sim/lib/workflows/orchestration/folder-lifecycle.ts b/apps/sim/lib/workflows/orchestration/folder-lifecycle.ts index 1bbe0866cab..ba6d2b8549d 100644 --- a/apps/sim/lib/workflows/orchestration/folder-lifecycle.ts +++ b/apps/sim/lib/workflows/orchestration/folder-lifecycle.ts @@ -474,32 +474,30 @@ export async function performRestoreFolder( ): Promise { const { folderId, workspaceId, userId, folderName } = params - const [existingFolder] = await db - .select() - .from(folder) - .where(and(eq(folder.id, folderId), eq(folder.workspaceId, workspaceId), isWorkflowFolder)) - - if (!existingFolder) { - return { success: false, error: 'Folder not found', errorCode: 'not_found' } - } - - if (!existingFolder.deletedAt) { - return { success: true, restoredItems: { folders: 0, workflows: 0 } } - } - - // 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 (existingFolder.locked) { - throw new ResourceLockedError('workflow', false, 'Folder is locked') - } - 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' } } - const restoredStats = await db.transaction(async (tx) => { + const outcome = await db.transaction(async (tx) => { + const [existingFolder] = await tx + .select() + .from(folder) + .where(and(eq(folder.id, folderId), eq(folder.workspaceId, workspaceId), isWorkflowFolder)) + + 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) { const [parentFolder] = await tx @@ -520,10 +518,25 @@ export async function performRestoreFolder( // TOCTOU window where a concurrent request locks resolvedParentId in between. await assertFolderMutable(resolvedParentId, 'workflow', tx) - return restoreFolderRecursively(folderId, workspaceId, existingFolder.deletedAt!, 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, @@ -531,15 +544,15 @@ export async function performRestoreFolder( action: AuditAction.FOLDER_RESTORED, resourceType: AuditResourceType.FOLDER, resourceId: folderId, - resourceName: folderName ?? existingFolder.name, - description: `Restored folder "${folderName ?? existingFolder.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 } } From f2c305f9adbb871e117292e2bdc3cabe54563885 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 10 Jul 2026 18:25:36 -0700 Subject: [PATCH 04/15] fix(folders): row-lock restore reads, map conflict errorCode to 409 Three more review findings this round: 1. statusForOrchestrationError never mapped errorCode: 'conflict' to a status, so it silently fell to 500. The folders PUT route (rename conflicts) and the restore route (file-folder name conflicts) both used this default, turning a legitimate 409 into a misleading 500. Added the conflict -> 409 case, matching the sibling workspaceFilesOrchestrationStatus mapper that already had it. 2. Switched the folders [id] PUT/DELETE routes and the restore route from their bespoke inline errorCode->status ternaries to the shared statusForOrchestrationError helper, now that every orchestration failure branch they consume sets an explicit errorCode (verified branch by branch) -- this was deliberately deferred in an earlier commit until that was true. Also closed two errorCode gaps this surfaced: the archived-workspace check in performRestoreFolder (workflow) had no errorCode, and performRestoreFileFolder's catch-all didn't recognize the 'Cannot restore folder into an archived workspace' message, so it was being mapped to a generic 500 by the previous commit's internal-error fallback. 3. Greptile: performRestoreFolder's (workflow) and performRestoreResourceFolder's (kb/table) restore-transaction reads of the folder's own locked/deletedAt columns were plain SELECTs, not SELECT ... FOR UPDATE -- being inside the same transaction as the write closes the round-trip race but not this one, since a plain read doesn't block a concurrent UPDATE from another transaction. Added FOR UPDATE to both, matching the existing restoreKnowledgeBase precedent. The file-folder restore path doesn't need this: it already takes a workspace-scoped pg_advisory_xact_lock that the lock-toggle path also acquires, so it was already race-free. --- apps/sim/app/api/folders/[id]/restore/route.ts | 11 +++++------ apps/sim/app/api/folders/[id]/route.ts | 15 +++++++++------ apps/sim/lib/folders/orchestration.ts | 8 ++++++++ .../workflows/orchestration/folder-lifecycle.ts | 11 ++++++++++- apps/sim/lib/workflows/orchestration/types.ts | 1 + 5 files changed, 33 insertions(+), 13 deletions(-) diff --git a/apps/sim/app/api/folders/[id]/restore/route.ts b/apps/sim/app/api/folders/[id]/restore/route.ts index 80b014e9e32..a881bb46ec9 100644 --- a/apps/sim/app/api/folders/[id]/restore/route.ts +++ b/apps/sim/app/api/folders/[id]/restore/route.ts @@ -11,6 +11,7 @@ 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 { statusForOrchestrationError } from '@/lib/workflows/orchestration/types' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' const logger = createLogger('RestoreFolderAPI') @@ -52,12 +53,10 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Route }) if (!result.success) { - // Only 'not_found' gets its own status here -- the other restore-failure - // shapes (validation, and file-folder's name-conflict case which omits - // errorCode) all read naturally as 400, matching this route's prior - // behavior for everything except "not found". - const status = result.errorCode === 'not_found' ? 404 : 400 - return NextResponse.json({ error: result.error }, { status }) + return NextResponse.json( + { error: result.error }, + { status: statusForOrchestrationError(result.errorCode) } + ) } logger.info(`Restored folder ${folderId}`, { restoredItems: result.restoredItems }) diff --git a/apps/sim/app/api/folders/[id]/route.ts b/apps/sim/app/api/folders/[id]/route.ts index 0a46dfa2f98..91554ffcd58 100644 --- a/apps/sim/app/api/folders/[id]/route.ts +++ b/apps/sim/app/api/folders/[id]/route.ts @@ -11,6 +11,7 @@ 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 { statusForOrchestrationError } from '@/lib/workflows/orchestration/types' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' const logger = createLogger('FoldersIDAPI') @@ -98,9 +99,10 @@ export const PUT = withRouteHandler( }) 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 }) @@ -161,9 +163,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( diff --git a/apps/sim/lib/folders/orchestration.ts b/apps/sim/lib/folders/orchestration.ts index 71588f970a0..04c526649c6 100644 --- a/apps/sim/lib/folders/orchestration.ts +++ b/apps/sim/lib/folders/orchestration.ts @@ -321,6 +321,9 @@ async function performRestoreFileFolder( 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' } } @@ -529,6 +532,10 @@ async function performRestoreResourceFolder { + // `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) @@ -539,6 +546,7 @@ async function performRestoreResourceFolder { + // `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 } 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 } From 056e33cca79ced98c9b9389a6cb9c8a0a1a01182 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 10 Jul 2026 18:43:32 -0700 Subject: [PATCH 05/15] fix(folders): close parent-lock write race, allow combined unlock+move Two more findings this round, both real: 1. Greptile (3x, same root cause): assertFolderMutable's ancestor-chain walk (getFolderLockStatus in the shared platform-authz engine) and the resource's-own-row check (getResourceLockStatus) both read with plain SELECTs, not SELECT ... FOR UPDATE. A concurrent lock toggle on a parent folder (or a resource's own row) could still commit between that read and the caller's write, even though the caller had already re-checked inside its own transaction. Added FOR UPDATE to both shared queries -- this is the single engine every lock check in the codebase goes through (reorder/restore/move/create/update/delete, all four resource types), so one fix closes it everywhere, including the two restore-path and one reorder-path call sites Greptile flagged individually. Reordering multiple folders in one batch can now row-lock several folders in a single transaction, so two concurrent reorder requests touching an overlapping folder set could deadlock if they lock in different orders. Sorted the id list before acquiring locks in performReorderFolders so concurrent batches always acquire in the same order. 2. Cursor: the table PATCH route's isLockOnlyUpdate gate was false whenever folderId also changed, so an admin combining locked: false with a move in one request still ran assertResourceMutable against the table's current (still-locked) state and was incorrectly rejected -- even though the request unlocks it as part of the same atomic write. Found and fixed the same bug class at three sibling call sites: renameTable (table), updateKnowledgeBase (knowledge base, folderId), performRenameWorkspaceFile (file, name), and the generic folders PUT route (any field). All four now skip the mutable pre-check specifically when this request also sets locked: false, not just when the update is lock-only. Added regression tests for the combined-unlock-plus-other-field-change case at all four sites. Full local verification passed (typecheck, full vitest suite 11430 tests, check:api-validation, lint all clean). --- apps/sim/app/api/folders/[id]/route.test.ts | 24 ++++++++++++++++++ apps/sim/app/api/folders/[id]/route.ts | 6 ++++- apps/sim/lib/folders/orchestration.ts | 15 +++++++---- apps/sim/lib/knowledge/service.test.ts | 23 +++++++++++++++++ apps/sim/lib/knowledge/service.ts | 6 ++++- apps/sim/lib/table/__tests__/service.test.ts | 25 +++++++++++++++++++ apps/sim/lib/table/service.ts | 6 ++++- .../file-folder-lifecycle.test.ts | 20 +++++++++++++++ .../orchestration/file-folder-lifecycle.ts | 5 +++- packages/platform-authz/src/resource-lock.ts | 10 ++++++++ 10 files changed, 131 insertions(+), 9 deletions(-) diff --git a/apps/sim/app/api/folders/[id]/route.test.ts b/apps/sim/app/api/folders/[id]/route.test.ts index 8fe8304b0cd..efc592666d7 100644 --- a/apps/sim/app/api/folders/[id]/route.test.ts +++ b/apps/sim/app/api/folders/[id]/route.test.ts @@ -10,6 +10,7 @@ import { type MockUser, permissionsMock, permissionsMockFns, + resourceLockMockFns, workflowsOrchestrationMockFns, } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -237,6 +238,29 @@ describe('Individual Folder API Route', () => { expect(mockPerformUpdateFolder).not.toHaveBeenCalled() }) + it('skips the mutable check when unlocking a 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. + mockAuthenticatedUser() + resourceLockMockFns.mockAssertFolderMutable.mockReset() + resourceLockMockFns.mockAssertFolderMutable.mockResolvedValue(undefined) + + 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(1) + expect(resourceLockMockFns.mockAssertFolderMutable).toHaveBeenCalledWith( + 'folder-2', + 'workflow' + ) + }) + it('should update parent folder successfully', async () => { mockAuthenticatedUser() diff --git a/apps/sim/app/api/folders/[id]/route.ts b/apps/sim/app/api/folders/[id]/route.ts index 91554ffcd58..186ba43a6fa 100644 --- a/apps/sim/app/api/folders/[id]/route.ts +++ b/apps/sim/app/api/folders/[id]/route.ts @@ -79,8 +79,12 @@ 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 block that. Skip only when this request isn't also + // unlocking. const hasNonLockUpdate = Object.keys(parsed.data.body).some((key) => key !== 'locked') - if (hasNonLockUpdate) { + if (hasNonLockUpdate && locked !== false) { await policy.assertMutable(id) } if (parentId !== undefined) { diff --git a/apps/sim/lib/folders/orchestration.ts b/apps/sim/lib/folders/orchestration.ts index 04c526649c6..c6831677bca 100644 --- a/apps/sim/lib/folders/orchestration.ts +++ b/apps/sim/lib/folders/orchestration.ts @@ -874,11 +874,16 @@ export async function performReorderFolders(params: PerformReorderFoldersParams) // parent in the window between that check and this transaction. Re-check // both inside the transaction (joining `tx` so the read is part of the // same atomic unit as the writes below) before applying anything. - for (const update of validUpdates) { - await assertFolderMutable(update.id, resourceType, tx) - } - for (const parentId of targetParentIds) { - await assertFolderMutable(parentId, resourceType, tx) + // + // assertFolderMutable now row-locks (FOR UPDATE) every folder it walks, so two + // concurrent reorder batches touching an overlapping set of folders must acquire + // those locks in the same order or they can deadlock. Sorting both id lists into + // a single deterministic order before locking guarantees that. + const lockOrder = Array.from( + new Set([...validUpdates.map((u) => u.id), ...targetParentIds]) + ).sort() + for (const id of lockOrder) { + await assertFolderMutable(id, resourceType, tx) } for (const update of validUpdates) { diff --git a/apps/sim/lib/knowledge/service.test.ts b/apps/sim/lib/knowledge/service.test.ts index bc17025a48c..8d77edf9e95 100644 --- a/apps/sim/lib/knowledge/service.test.ts +++ b/apps/sim/lib/knowledge/service.test.ts @@ -305,6 +305,29 @@ describe('updateKnowledgeBase — resource-lock enforcement', () => { expect(mockAssertResourceMutable).not.toHaveBeenCalled() }) + it('skips the lock check when unlocking a 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. + 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 + + await updateKnowledgeBase('kb-1', { folderId: 'folder-1', locked: false }, 'req-1').catch( + () => undefined + ) + + expect(mockAssertResourceMutable).not.toHaveBeenCalled() + expect(mockAssertFolderMutable).toHaveBeenCalledWith( + 'folder-1', + 'knowledge_base', + expect.anything() + ) + }) + 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, diff --git a/apps/sim/lib/knowledge/service.ts b/apps/sim/lib/knowledge/service.ts index 87bf310a858..c985712410e 100644 --- a/apps/sim/lib/knowledge/service.ts +++ b/apps/sim/lib/knowledge/service.ts @@ -313,7 +313,11 @@ export async function updateKnowledgeBase( updates.workspaceId !== undefined || updates.folderId !== undefined || updates.chunkingConfig !== undefined - if (hasNonLockUpdate) { + // 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 block that. Skip only when this request isn't also + // unlocking. + if (hasNonLockUpdate && updates.locked !== false) { await assertResourceMutable('knowledge_base', knowledgeBaseId) } diff --git a/apps/sim/lib/table/__tests__/service.test.ts b/apps/sim/lib/table/__tests__/service.test.ts index 08661e07fea..1120889da67 100644 --- a/apps/sim/lib/table/__tests__/service.test.ts +++ b/apps/sim/lib/table/__tests__/service.test.ts @@ -93,6 +93,31 @@ describe('table service — resource-lock enforcement', () => { expect(resourceLockMockFns.mockAssertResourceMutable).not.toHaveBeenCalled() }) + it('skips the lock check when unlocking a 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. + 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, + }, + ]) + + await renameTable('tbl-1', 'new_name', 'req-1', undefined, 'folder-1', false, false) + + expect(resourceLockMockFns.mockAssertResourceMutable).not.toHaveBeenCalled() + expect(resourceLockMockFns.mockAssertFolderMutable).toHaveBeenCalledWith('folder-1', 'table') + expect(dbChainMockFns.update).toHaveBeenCalledTimes(1) + }) + 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, diff --git a/apps/sim/lib/table/service.ts b/apps/sim/lib/table/service.ts index a1034c167ef..0610d45493a 100644 --- a/apps/sim/lib/table/service.ts +++ b/apps/sim/lib/table/service.ts @@ -575,7 +575,11 @@ export async function renameTable( throw new Error(nameValidation.errors.join(', ')) } - if (!isLockOnlyUpdate) { + // `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 block + // that. Skip only when this request isn't also unlocking. + if (!isLockOnlyUpdate && locked !== false) { await assertResourceMutable('table', tableId) } 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 index fb52fc57e63..8be831c8908 100644 --- a/apps/sim/lib/workspace-files/orchestration/file-folder-lifecycle.test.ts +++ b/apps/sim/lib/workspace-files/orchestration/file-folder-lifecycle.test.ts @@ -127,6 +127,26 @@ describe('workspace-files orchestration — resource-lock enforcement', () => { expect(resourceLockMockFns.mockAssertResourceMutable).not.toHaveBeenCalled() }) + + it('skips the lock check when unlocking a 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. + mockRenameWorkspaceFile.mockResolvedValueOnce({ id: 'file-1', name: 'renamed.txt' }) + + await performRenameWorkspaceFile({ + workspaceId: 'ws-1', + fileId: 'file-1', + name: 'renamed.txt', + userId: 'user-1', + locked: false, + isLockOnlyUpdate: false, + }) + + expect(resourceLockMockFns.mockAssertResourceMutable).not.toHaveBeenCalled() + }) }) describe('performDeleteWorkspaceFileItems', () => { 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 835772588a2..7c44bd07283 100644 --- a/apps/sim/lib/workspace-files/orchestration/file-folder-lifecycle.ts +++ b/apps/sim/lib/workspace-files/orchestration/file-folder-lifecycle.ts @@ -313,7 +313,10 @@ export async function performRenameWorkspaceFile( const { workspaceId, fileId, name, userId, locked, isLockOnlyUpdate } = params try { - if (!isLockOnlyUpdate) { + // 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 + // block that. Skip only when this request isn't also unlocking. + if (!isLockOnlyUpdate && locked !== false) { await assertResourceMutable('file', fileId) } diff --git a/packages/platform-authz/src/resource-lock.ts b/packages/platform-authz/src/resource-lock.ts index 7620fd9f18a..ba7152911a1 100644 --- a/packages/platform-authz/src/resource-lock.ts +++ b/packages/platform-authz/src/resource-lock.ts @@ -117,6 +117,12 @@ export async function getFolderLockStatus( 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, @@ -131,6 +137,7 @@ export async function getFolderLockStatus( isNull(folder.deletedAt) ) ) + .for('update') .limit(1) if (!folderRow) break @@ -162,6 +169,8 @@ export async function getResourceLockStatus( ): 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, @@ -169,6 +178,7 @@ export async function getResourceLockStatus( }) .from(config.table) .where(eq(config.idColumn, resourceId)) + .for('update') .limit(1) if (!row) return UNLOCKED_STATUS From b4a55a21a004cd91e5b9fab047ec1e3ef7d56f51 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 10 Jul 2026 18:58:50 -0700 Subject: [PATCH 06/15] fix(folders): lock the full ancestor closure in one ordered statement Two more findings this round: 1. Greptile (correctly pushing back on the previous round's fix): sorting the *starting* ids before calling assertFolderMutable on each doesn't actually prevent deadlocks in a reorder batch. Each call still walks and locks its own ancestor chain one row at a time, and two concurrent batches whose chains interleave differently (e.g. one locks folder A then their shared ancestor C, the other locks folder B then also wants C) can still each end up holding a row the other needs. Sorting the starting ids only orders the entry points, not the actual sequence of rows locked. The only way to make batch-vs-batch lock acquisition deadlock-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. Replaced the per-id assertFolderMutable loop in performReorderFolders with: a read-only breadth-first walk (no locks) to collect the full ancestor closure of every folder being reordered plus every target parent, then one `WHERE id IN (closure) ORDER BY id FOR UPDATE` statement that locks the entire closure atomically. Concurrent batches that do the same always converge on the same acquisition order and cannot form a cycle. 2. Cursor: the folder-duplicate route's assertTargetParentFolderMutable walks the target parent's ancestor chain with the same pattern the shared engine had before the previous round's fix -- a plain SELECT, not SELECT ... FOR UPDATE, so a parent could still be locked between this read and the new folder row being inserted. This one is a single linear ancestor walk (not a multi-root batch), so it doesn't have the deadlock risk above -- just needed the same FOR UPDATE fix already applied to the shared engine. Updated the reorder route's tests for the new locking shape (a closure walk plus one ordered lock read instead of N assertFolderMutable calls), added a regression test for locking an ancestor beyond the batch's explicit ids, and fixed the duplicate route's test mock to support the FOR UPDATE chain. Full local verification passed (typecheck, full vitest suite 11431 tests, check:api-validation, lint all clean). --- .../api/folders/[id]/duplicate/route.test.ts | 12 +-- .../app/api/folders/[id]/duplicate/route.ts | 3 + .../sim/app/api/folders/reorder/route.test.ts | 76 ++++++++++++++++--- apps/sim/lib/folders/orchestration.ts | 61 ++++++++++++--- 4 files changed, 125 insertions(+), 27 deletions(-) diff --git a/apps/sim/app/api/folders/[id]/duplicate/route.test.ts b/apps/sim/app/api/folders/[id]/duplicate/route.test.ts index 531b6a0ff21..63f523fe8bc 100644 --- a/apps/sim/app/api/folders/[id]/duplicate/route.test.ts +++ b/apps/sim/app/api/folders/[id]/duplicate/route.test.ts @@ -19,15 +19,17 @@ describe('assertTargetParentFolderMutable', () => { */ 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({ - limit: vi.fn().mockImplementation(() => { - const row = chain[call] - call += 1 - return row ? [row] : [] - }), + for: vi.fn().mockReturnValue({ limit }), + limit, }), }), }), diff --git a/apps/sim/app/api/folders/[id]/duplicate/route.ts b/apps/sim/app/api/folders/[id]/duplicate/route.ts index c038e18c579..f6cb4d7d442 100644 --- a/apps/sim/app/api/folders/[id]/duplicate/route.ts +++ b/apps/sim/app/api/folders/[id]/duplicate/route.ts @@ -269,6 +269,8 @@ export async function assertTargetParentFolderMutable( 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: folderTable.id, @@ -280,6 +282,7 @@ export async function assertTargetParentFolderMutable( }) .from(folderTable) .where(eq(folderTable.id, currentFolderId)) + .for('update') .limit(1) if ( diff --git a/apps/sim/app/api/folders/reorder/route.test.ts b/apps/sim/app/api/folders/reorder/route.test.ts index 16c140ff472..a59168d9994 100644 --- a/apps/sim/app/api/folders/reorder/route.test.ts +++ b/apps/sim/app/api/folders/reorder/route.test.ts @@ -4,7 +4,6 @@ * @vitest-environment node */ -import { ResourceLockedError } from '@sim/platform-authz/resource-lock' import { authMockFns, createMockRequest, @@ -49,6 +48,30 @@ describe('PUT /api/folders/reorder', () => { 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() @@ -67,9 +90,9 @@ describe('PUT /api/folders/reorder', () => { }), }), }) - mockTxSelect.mockReturnValue({ - from: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }), - }) + // 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, select: mockTxSelect }) ) @@ -302,8 +325,8 @@ describe('PUT /api/folders/reorder', () => { // 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 third assertFolderMutable call is the new - // in-transaction recheck this test targets. + // 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' }, @@ -311,10 +334,7 @@ describe('PUT /api/folders/reorder', () => { .mockReturnValueOnce([{ id: 'folder-1', parentId: null }]) .mockReturnValueOnce([{ id: 'folder-1', workspaceId: 'workspace-123' }]) - resourceLockMockFns.mockAssertFolderMutable - .mockResolvedValueOnce(undefined) - .mockResolvedValueOnce(undefined) - .mockRejectedValueOnce(new ResourceLockedError('workflow', false, 'Folder is locked')) + queueTxSelectResults([{ id: 'folder-1', parentId: null }], [{ id: 'folder-1', locked: true }]) const req = createMockRequest('PUT', { workspaceId: 'workspace-123', @@ -324,11 +344,45 @@ describe('PUT /api/folders/reorder', () => { const response = await PUT(req) expect(response.status).toBe(423) - expect(resourceLockMockFns.mockAssertFolderMutable).toHaveBeenCalledTimes(3) 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 a batch that would form a cycle', async () => { mockWhere .mockReturnValueOnce([ diff --git a/apps/sim/lib/folders/orchestration.ts b/apps/sim/lib/folders/orchestration.ts index c6831677bca..c28b18350d9 100644 --- a/apps/sim/lib/folders/orchestration.ts +++ b/apps/sim/lib/folders/orchestration.ts @@ -872,18 +872,57 @@ export async function performReorderFolders(params: PerformReorderFoldersParams) // 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 - // both inside the transaction (joining `tx` so the read is part of the - // same atomic unit as the writes below) before applying anything. + // inside the transaction (joining `tx` so the read is part of the same + // atomic unit as the writes below) before applying anything. // - // assertFolderMutable now row-locks (FOR UPDATE) every folder it walks, so two - // concurrent reorder batches touching an overlapping set of folders must acquire - // those locks in the same order or they can deadlock. Sorting both id lists into - // a single deterministic order before locking guarantees that. - const lockOrder = Array.from( - new Set([...validUpdates.map((u) => u.id), ...targetParentIds]) - ).sort() - for (const id of lockOrder) { - await assertFolderMutable(id, resourceType, tx) + // 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([...validUpdates.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 + } + + const lockedRows = await tx + .select({ id: folderTable.id, locked: folderTable.locked }) + .from(folderTable) + .where( + and( + inArray(folderTable.id, Array.from(closure).sort()), + eq(folderTable.resourceType, resourceType) + ) + ) + .orderBy(folderTable.id) + .for('update') + + if (lockedRows.some((row) => row.locked)) { + throw new ResourceLockedError(resourceType, false, 'Folder is locked') } for (const update of validUpdates) { From 411570a50beeeb9f925035ea72aa8371385b802c Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 10 Jul 2026 19:19:51 -0700 Subject: [PATCH 07/15] fix(folders): close parent-delete race in restore, fix file-folder errorCodes Two more findings this round: 1. Greptile P1: performRestoreFolder (workflow) and performRestoreResourceFolder (kb/table) both resolve the target parent by reading its deletedAt inside the transaction, but that read was a plain SELECT, not SELECT ... FOR UPDATE. A parent could still be soft-deleted by a concurrent transaction between that read and the restore write below it, leaving the restored folder pointing at a now-deleted parent. Added FOR UPDATE to both reads. Also closed the same class of gap in performReorderFolders: the target-parent active check ran as a separate, unlocked pre-check before the closure-based lock query introduced last round. Folded it into the closure-lock query instead of adding a second incremental lock -- the closure query now also selects deletedAt and re-validates every target parent resolved to an active row only after FOR UPDATE is held on it, which is the only point where the check is actually race-free (a row's deletedAt can't change while the lock is held). A second, separately-ordered lock acquisition here would have reintroduced the exact deadlock class fixed last round. 2. Cursor: performUpdateFileFolder only recognized 'Folder not found' from updateWorkspaceFileFolder's thrown errors; the other three messages it can throw ('Folder cannot be its own parent', 'Target folder not found', 'Cannot move a folder into one of its descendants') fell through to the generic errorCode: 'internal' bucket, so the folder PUT route (now using statusForOrchestrationError) returned 500 for what are actually validation failures. Mapped all three to errorCode: 'validation'. Found and fixed the same gap in performCreateFileFolder, which had an identical unhandled 'Target folder not found' case from createWorkspaceFileFolder. Added a regression test for the reorder parent-delete race. Full local verification passed (typecheck, full vitest suite 11432 tests, check:api-validation, lint all clean). --- .../sim/app/api/folders/reorder/route.test.ts | 43 +++++++++++++ apps/sim/lib/folders/orchestration.ts | 60 +++++++++++++------ .../orchestration/folder-lifecycle.ts | 6 ++ 3 files changed, 91 insertions(+), 18 deletions(-) diff --git a/apps/sim/app/api/folders/reorder/route.test.ts b/apps/sim/app/api/folders/reorder/route.test.ts index a59168d9994..ad1a4f2603e 100644 --- a/apps/sim/app/api/folders/reorder/route.test.ts +++ b/apps/sim/app/api/folders/reorder/route.test.ts @@ -383,6 +383,49 @@ describe('PUT /api/folders/reorder', () => { 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 a batch that would form a cycle', async () => { mockWhere .mockReturnValueOnce([ diff --git a/apps/sim/lib/folders/orchestration.ts b/apps/sim/lib/folders/orchestration.ts index c28b18350d9..ef45b787c28 100644 --- a/apps/sim/lib/folders/orchestration.ts +++ b/apps/sim/lib/folders/orchestration.ts @@ -183,6 +183,9 @@ async function performCreateFileFolder( ) { 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' } } @@ -230,8 +233,16 @@ async function performUpdateFileFolder( ) { return { success: false, error: toError(error).message, errorCode: 'conflict' } } - if (toError(error).message === 'Folder not found') { - return { success: false, error: 'Folder not found', errorCode: 'not_found' } + 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' } @@ -603,10 +614,16 @@ async function performRestoreResourceFolder { - // Re-check each target parent is still active inside the transaction -- - // assertFolderParentValid above only reads at validation time, so a parent - // concurrently soft-deleted before this transaction opens could otherwise - // leave an active folder pointing at a deleted one. - if (targetParentIds.length > 0) { - const activeParents = await tx - .select({ id: folderTable.id }) - .from(folderTable) - .where(and(inArray(folderTable.id, targetParentIds), isNull(folderTable.deletedAt))) - if (activeParents.length !== targetParentIds.length) { - throw new FolderReorderNotFoundError('Parent folder not found') - } - } - // 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 @@ -909,8 +912,18 @@ export async function performReorderFolders(params: PerformReorderFoldersParams) 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, locked: folderTable.locked }) + .select({ + id: folderTable.id, + locked: folderTable.locked, + deletedAt: folderTable.deletedAt, + }) .from(folderTable) .where( and( @@ -921,7 +934,18 @@ export async function performReorderFolders(params: PerformReorderFoldersParams) .orderBy(folderTable.id) .for('update') - if (lockedRows.some((row) => row.locked)) { + 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') } diff --git a/apps/sim/lib/workflows/orchestration/folder-lifecycle.ts b/apps/sim/lib/workflows/orchestration/folder-lifecycle.ts index 951dd25d4fa..f7be2dae16d 100644 --- a/apps/sim/lib/workflows/orchestration/folder-lifecycle.ts +++ b/apps/sim/lib/workflows/orchestration/folder-lifecycle.ts @@ -509,10 +509,16 @@ export async function performRestoreFolder( 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({ deletedAt: folder.deletedAt }) .from(folder) .where(eq(folder.id, resolvedParentId)) + .for('update') if (!parentFolder || parentFolder.deletedAt) { resolvedParentId = null From 8ac1ab668b15afe823dd683fbd213dd4abae0582 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 10 Jul 2026 19:38:53 -0700 Subject: [PATCH 08/15] fix(folders): fix source-lock bypass in unlock skip, add in-tx recheck to updates Two more findings this round: 1. Greptile P1 security: the "combined unlock + other field change" fix from an earlier round skipped the mutable pre-check entirely whenever the request set locked: false, but that check also covers a lock INHERITED from an ancestor folder -- clearing this object's own locked flag has no effect on that. An admin could unlock a folder while also moving it out from under a still-locked ancestor, since the ancestor-chain check was skipped along with the direct one. Replaced the "skip if unlocking" pattern with two new shared primitives, assertFolderMutableUnlessUnlocking / assertResourceMutableUnlessUnlocking (packages/platform-authz/src/resource-lock.ts): they still run the full mutable check, and only swallow a DIRECT (non-inherited) lock error when the request is unlocking in the same write. An inherited lock still blocks. Applied at all 5 call sites that had the old broad skip: the generic folders PUT route, renameTable, updateKnowledgeBase, performRenameWorkspaceFile, and the new in-transaction recheck added below. 2. Cursor: generic folder updates for knowledge_base/table (performUpdateFolder's plain branch) and file-folder updates (updateWorkspaceFileFolder) applied their UPDATE from a single statement after only the route's pre-transaction assertMutable call -- unlike reorder/restore, there was no in-transaction lock recheck, so a folder or ancestor locked in the window between that pre-check and the write could still be mutated. Wrapped the kb/table plain update path in a transaction with an in-tx recheck (joining `tx` so it's part of the same atomic unit as the write); added the equivalent recheck to updateWorkspaceFileFolder's existing transaction, plus a target-parent lock check that was previously entirely missing there (existence was checked, lock state was not). Updated the 4 existing "combined unlock" tests to reflect the corrected behavior (the check still runs; a direct lock is satisfied by unlocking, an inherited lock is not) and added a companion "still rejects when inherited" test at each of those 4 sites. Updated the shared @sim/testing resource-lock mock and knowledge/service.test.ts's local mock to implement the new wrappers' real catch logic rather than a bare passthrough, so tests that configure a rejection see the same behavior as production. Full local verification passed (typecheck, full vitest suite 11436 tests, check:api-validation, lint all clean). --- apps/sim/app/api/folders/[id]/route.test.ts | 33 +++++++- apps/sim/app/api/folders/[id]/route.ts | 14 ++-- apps/sim/lib/folders/orchestration.ts | 57 ++++++++++--- apps/sim/lib/knowledge/service.test.ts | 81 ++++++++++++++----- apps/sim/lib/knowledge/service.ts | 20 +++-- apps/sim/lib/table/__tests__/service.test.ts | 25 +++++- apps/sim/lib/table/service.ts | 16 ++-- .../workspace-file-folder-manager.ts | 22 +++++ .../file-folder-lifecycle.test.ts | 32 +++++++- .../orchestration/file-folder-lifecycle.ts | 9 ++- packages/platform-authz/src/resource-lock.ts | 37 +++++++++ .../testing/src/mocks/resource-lock.mock.ts | 50 +++++++++++- 12 files changed, 335 insertions(+), 61 deletions(-) diff --git a/apps/sim/app/api/folders/[id]/route.test.ts b/apps/sim/app/api/folders/[id]/route.test.ts index efc592666d7..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, @@ -238,15 +239,20 @@ describe('Individual Folder API Route', () => { expect(mockPerformUpdateFolder).not.toHaveBeenCalled() }) - it('skips the mutable check when unlocking a locked folder combined with a move in the same request', async () => { + 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. + // 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' }) @@ -254,13 +260,34 @@ describe('Individual Folder API Route', () => { const response = await PUT(req, { params }) expect(response.status).toBe(200) - expect(resourceLockMockFns.mockAssertFolderMutable).toHaveBeenCalledTimes(1) + 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() diff --git a/apps/sim/app/api/folders/[id]/route.ts b/apps/sim/app/api/folders/[id]/route.ts index 186ba43a6fa..980dab74d88 100644 --- a/apps/sim/app/api/folders/[id]/route.ts +++ b/apps/sim/app/api/folders/[id]/route.ts @@ -1,7 +1,10 @@ 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 { + 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' @@ -81,11 +84,12 @@ 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 block that. Skip only when this request isn't also - // unlocking. + // 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 && locked !== false) { - await policy.assertMutable(id) + if (hasNonLockUpdate) { + await assertFolderMutableUnlessUnlocking(id, existingFolder.resourceType, locked === false) } if (parentId !== undefined) { await policy.assertMutable(parentId) diff --git a/apps/sim/lib/folders/orchestration.ts b/apps/sim/lib/folders/orchestration.ts index ef45b787c28..283f905c29f 100644 --- a/apps/sim/lib/folders/orchestration.ts +++ b/apps/sim/lib/folders/orchestration.ts @@ -2,7 +2,11 @@ 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, ResourceLockedError } from '@sim/platform-authz/resource-lock' +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 } from 'drizzle-orm' @@ -753,18 +757,47 @@ export async function performUpdateFolder( if (params.sortOrder !== undefined) updates.sortOrder = params.sortOrder if (params.locked !== undefined) updates.locked = params.locked - const [updated] = await db - .update(folderTable) - .set(updates) - .where( - and( - eq(folderTable.id, params.folderId), - eq(folderTable.workspaceId, params.workspaceId), - eq(folderTable.resourceType, params.resourceType), - isNull(folderTable.deletedAt) + const isLockOnlyUpdate = + params.name === undefined && params.parentId === undefined && params.sortOrder === undefined + + 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 ) - ) - .returning() + } + if (params.parentId) { + // A folder in an unlocked location could otherwise be moved into a locked one. + await assertFolderMutable(params.parentId, params.resourceType, tx) + } + + 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' } return { success: true, folder: updated } diff --git a/apps/sim/lib/knowledge/service.test.ts b/apps/sim/lib/knowledge/service.test.ts index 8d77edf9e95..c55dbd65109 100644 --- a/apps/sim/lib/knowledge/service.test.ts +++ b/apps/sim/lib/knowledge/service.test.ts @@ -12,32 +12,52 @@ 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, 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 { + 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 } - return { - mockAssertResourceMutable: vi.fn(), - mockAssertFolderMutable: vi.fn(), - MockResourceLockedError: ResourceLockedErrorStub, + } + 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, })) @@ -305,22 +325,27 @@ describe('updateKnowledgeBase — resource-lock enforcement', () => { expect(mockAssertResourceMutable).not.toHaveBeenCalled() }) - it('skips the lock check when unlocking a locked knowledge base combined with a move in the same request', async () => { + 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. + // 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).not.toHaveBeenCalled() + expect(mockAssertResourceMutable).toHaveBeenCalledWith('knowledge_base', 'kb-1') expect(mockAssertFolderMutable).toHaveBeenCalledWith( 'folder-1', 'knowledge_base', @@ -328,6 +353,22 @@ describe('updateKnowledgeBase — resource-lock enforcement', () => { ) }) + 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, diff --git a/apps/sim/lib/knowledge/service.ts b/apps/sim/lib/knowledge/service.ts index c985712410e..2f401c6ae0c 100644 --- a/apps/sim/lib/knowledge/service.ts +++ b/apps/sim/lib/knowledge/service.ts @@ -8,7 +8,11 @@ import { workspaceFiles, } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { assertFolderMutable, assertResourceMutable } from '@sim/platform-authz/resource-lock' +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' @@ -315,10 +319,16 @@ export async function updateKnowledgeBase( 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 block that. Skip only when this request isn't also - // unlocking. - if (hasNonLockUpdate && updates.locked !== false) { - await assertResourceMutable('knowledge_base', knowledgeBaseId) + // 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) { diff --git a/apps/sim/lib/table/__tests__/service.test.ts b/apps/sim/lib/table/__tests__/service.test.ts index 1120889da67..517f848623a 100644 --- a/apps/sim/lib/table/__tests__/service.test.ts +++ b/apps/sim/lib/table/__tests__/service.test.ts @@ -93,11 +93,13 @@ describe('table service — resource-lock enforcement', () => { expect(resourceLockMockFns.mockAssertResourceMutable).not.toHaveBeenCalled() }) - it('skips the lock check when unlocking a locked table combined with a move in the same request', async () => { + 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. + // 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 @@ -110,14 +112,31 @@ describe('table service — resource-lock enforcement', () => { 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).not.toHaveBeenCalled() + expect(resourceLockMockFns.mockAssertResourceMutable).toHaveBeenCalledWith('table', 'tbl-1') expect(resourceLockMockFns.mockAssertFolderMutable).toHaveBeenCalledWith('folder-1', 'table') 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, diff --git a/apps/sim/lib/table/service.ts b/apps/sim/lib/table/service.ts index 0610d45493a..99669210c0b 100644 --- a/apps/sim/lib/table/service.ts +++ b/apps/sim/lib/table/service.ts @@ -11,7 +11,11 @@ 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 } from '@sim/platform-authz/resource-lock' +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' @@ -577,10 +581,12 @@ export async function renameTable( // `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 block - // that. Skip only when this request isn't also unlocking. - if (!isLockOnlyUpdate && locked !== false) { - await assertResourceMutable('table', tableId) + // 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) } if (folderId) { 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 73fb8df187b..94451aa918e 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 @@ -588,6 +588,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 @@ -615,6 +630,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) { 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 index 8be831c8908..68e025200b2 100644 --- a/apps/sim/lib/workspace-files/orchestration/file-folder-lifecycle.test.ts +++ b/apps/sim/lib/workspace-files/orchestration/file-folder-lifecycle.test.ts @@ -128,13 +128,18 @@ describe('workspace-files orchestration — resource-lock enforcement', () => { expect(resourceLockMockFns.mockAssertResourceMutable).not.toHaveBeenCalled() }) - it('skips the lock check when unlocking a locked file combined with a rename in the same request', async () => { + 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. + // 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', @@ -145,7 +150,28 @@ describe('workspace-files orchestration — resource-lock enforcement', () => { isLockOnlyUpdate: false, }) - expect(resourceLockMockFns.mockAssertResourceMutable).not.toHaveBeenCalled() + 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() }) }) 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 7c44bd07283..32faa0804cb 100644 --- a/apps/sim/lib/workspace-files/orchestration/file-folder-lifecycle.ts +++ b/apps/sim/lib/workspace-files/orchestration/file-folder-lifecycle.ts @@ -3,6 +3,7 @@ import { createLogger } from '@sim/logger' import { assertFolderMutable, assertResourceMutable, + assertResourceMutableUnlessUnlocking, ResourceLockedError, } from '@sim/platform-authz/resource-lock' import { getPostgresErrorCode, toError } from '@sim/utils/errors' @@ -315,9 +316,11 @@ export async function performRenameWorkspaceFile( try { // 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 - // block that. Skip only when this request isn't also unlocking. - if (!isLockOnlyUpdate && locked !== false) { - await assertResourceMutable('file', fileId) + // 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) diff --git a/packages/platform-authz/src/resource-lock.ts b/packages/platform-authz/src/resource-lock.ts index ba7152911a1..8c673c52963 100644 --- a/packages/platform-authz/src/resource-lock.ts +++ b/packages/platform-authz/src/resource-lock.ts @@ -227,3 +227,40 @@ export async function assertResourceMutable( ) } } + +/** + * Like {@link assertFolderMutable}, but when `unlocking` is true, a DIRECT lock on + * `folderId` itself is treated as satisfied -- 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. An INHERITED lock (from a still-locked + * ancestor) still blocks regardless, since clearing this folder's own flag has no + * effect on its ancestors. + */ +export async function assertFolderMutableUnlessUnlocking( + folderId: string | null, + resourceType: FolderResourceType, + unlocking: boolean, + dbClient: DbOrTx = db +): Promise { + try { + await assertFolderMutable(folderId, resourceType, dbClient) + } catch (error) { + if (unlocking && error instanceof ResourceLockedError && !error.inherited) return + throw error + } +} + +/** Resource-level counterpart to {@link assertFolderMutableUnlessUnlocking}. */ +export async function assertResourceMutableUnlessUnlocking( + resourceType: FolderResourceType, + resourceId: string, + unlocking: boolean, + dbClient: DbOrTx = db +): Promise { + try { + await assertResourceMutable(resourceType, resourceId, dbClient) + } catch (error) { + if (unlocking && error instanceof ResourceLockedError && !error.inherited) return + throw error + } +} diff --git a/packages/testing/src/mocks/resource-lock.mock.ts b/packages/testing/src/mocks/resource-lock.mock.ts index 13b25b5d8e3..4d18c6cac65 100644 --- a/packages/testing/src/mocks/resource-lock.mock.ts +++ b/packages/testing/src/mocks/resource-lock.mock.ts @@ -41,11 +41,54 @@ const unlockedStatus = { * ) * ``` */ +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: vi.fn().mockResolvedValue(undefined), - mockAssertResourceMutable: vi.fn().mockResolvedValue(undefined), + mockAssertFolderMutable, + mockAssertResourceMutable, + mockAssertFolderMutableUnlessUnlocking: vi.fn(assertFolderMutableUnlessUnlocking), + mockAssertResourceMutableUnlessUnlocking: vi.fn(assertResourceMutableUnlessUnlocking), } /** @@ -61,5 +104,8 @@ export const resourceLockMock = { getResourceLockStatus: resourceLockMockFns.mockGetResourceLockStatus, assertFolderMutable: resourceLockMockFns.mockAssertFolderMutable, assertResourceMutable: resourceLockMockFns.mockAssertResourceMutable, + assertFolderMutableUnlessUnlocking: resourceLockMockFns.mockAssertFolderMutableUnlessUnlocking, + assertResourceMutableUnlessUnlocking: + resourceLockMockFns.mockAssertResourceMutableUnlessUnlocking, ResourceLockedError: MockResourceLockedError, } From 0976cafa8b4ba1bb9e908738766bc58e7b7957c7 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 10 Jul 2026 19:51:53 -0700 Subject: [PATCH 09/15] fix(folders): recheck cycle-freedom inside the reorder transaction Greptile P1: the route's circular-reference check reads an unlocked snapshot of the folder tree before the transaction opens. Two concurrent reorder requests can each move one end of what becomes an A<->B cycle -- each request's own cycle check only sees its own proposed edge merged with a stale snapshot of the other folder's parentId, so both can pass independently and, once both commit, leave a genuine cycle in the tree that the write path never re-validates. A cycle can only newly appear through an edge this batch is writing, and every folder whose parentId is being changed is already a `startId` that the closure-lock query (from an earlier round's deadlock fix) walks and locks. So the fix reuses that same locked, transaction-consistent data: after acquiring FOR UPDATE on the closure, build a parentId map from the locked rows (overridden by this batch's own proposed new parentIds) and re-run the identical cycle-walk the route already does, but against data that can no longer change out from under it. A cycle found here now rolls back the whole transaction with the same 400/'Cannot create circular folder reference' response the pre-check already returns for the same-request case. Added a regression test simulating the race: the route's pre-check snapshot shows the target parent's parentId as still null, while the locked read inside the transaction reflects a concurrent request that already committed the other half of the cycle. Full local verification passed (typecheck, full vitest suite 11437 tests, check:api-validation, lint all clean). --- .../sim/app/api/folders/reorder/route.test.ts | 44 +++++++++++++++++++ apps/sim/lib/folders/orchestration.ts | 32 ++++++++++++++ 2 files changed, 76 insertions(+) diff --git a/apps/sim/app/api/folders/reorder/route.test.ts b/apps/sim/app/api/folders/reorder/route.test.ts index ad1a4f2603e..1d812150f6a 100644 --- a/apps/sim/app/api/folders/reorder/route.test.ts +++ b/apps/sim/app/api/folders/reorder/route.test.ts @@ -426,6 +426,50 @@ describe('PUT /api/folders/reorder', () => { 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/lib/folders/orchestration.ts b/apps/sim/lib/folders/orchestration.ts index 283f905c29f..7be1d34859d 100644 --- a/apps/sim/lib/folders/orchestration.ts +++ b/apps/sim/lib/folders/orchestration.ts @@ -841,6 +841,8 @@ export async function performRestoreFolder( /** 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 FolderReorderCycleError extends Error {} export async function performReorderFolders(params: PerformReorderFoldersParams): Promise<{ success: boolean @@ -954,6 +956,7 @@ export async function performReorderFolders(params: PerformReorderFoldersParams) const lockedRows = await tx .select({ id: folderTable.id, + parentId: folderTable.parentId, locked: folderTable.locked, deletedAt: folderTable.deletedAt, }) @@ -982,6 +985,32 @@ export async function performReorderFolders(params: PerformReorderFoldersParams) throw new ResourceLockedError(resourceType, false, 'Folder is locked') } + // 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 validUpdates) { + if (update.parentId !== undefined) { + lockedParentById.set(update.id, update.parentId || null) + } + } + for (const update of validUpdates) { + const visited = new Set() + let cursor: string | null = update.id + while (cursor) { + if (visited.has(cursor)) { + throw new FolderReorderCycleError('Cannot create circular folder reference') + } + visited.add(cursor) + cursor = lockedParentById.get(cursor) ?? null + } + } + for (const update of validUpdates) { const updateData: Record = { sortOrder: update.sortOrder, @@ -1012,6 +1041,9 @@ export async function performReorderFolders(params: PerformReorderFoldersParams) if (error instanceof FolderReorderNotFoundError) { return { success: false, updated: 0, error: error.message, errorCode: 'not_found' } } + if (error instanceof FolderReorderCycleError) { + return { success: false, updated: 0, error: error.message, errorCode: 'validation' } + } // 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 }) From 7685991c1cd42424ccb25a705f74b422bc24f94f Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 10 Jul 2026 20:02:42 -0700 Subject: [PATCH 10/15] fix(folders): add in-tx lock recheck to workflow-folder update Greptile P1 security: this workflow-specific performUpdateFolder wrote directly after only the route-level assertMutable precheck, with no in-transaction recheck -- unlike the generic knowledge_base/table update path and the file-folder update path, both fixed for the identical gap two rounds ago. Missed this one because it lives in a separate file (workflows/orchestration/folder-lifecycle.ts) and dispatches independently from the generic branch in folders/orchestration.ts, rather than sharing that fixed code path. Wrapped the write in a transaction and added the same in-tx recheck used at the other three sites: assertFolderMutableUnlessUnlocking for this folder's own lock (treats a direct, about-to-be-cleared lock as satisfied when this request unlocks it, but still enforces an inherited ancestor lock), skipped entirely for a lock-only update, plus a target-parent assertFolderMutable check when reparenting. Added a ResourceLockedError rethrow in the catch block so the route's 423 handling still applies -- the previous catch-all would otherwise have swallowed it into a generic 500. Full local verification passed (typecheck, full vitest suite 11437 tests, check:api-validation, lint all clean). --- .../orchestration/folder-lifecycle.ts | 61 +++++++++++++++---- 1 file changed, 49 insertions(+), 12 deletions(-) diff --git a/apps/sim/lib/workflows/orchestration/folder-lifecycle.ts b/apps/sim/lib/workflows/orchestration/folder-lifecycle.ts index f7be2dae16d..039d76722d4 100644 --- a/apps/sim/lib/workflows/orchestration/folder-lifecycle.ts +++ b/apps/sim/lib/workflows/orchestration/folder-lifecycle.ts @@ -2,7 +2,11 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { chat, folder, webhook, workflow, workflowMcpTool, workflowSchedule } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { assertFolderMutable, ResourceLockedError } from '@sim/platform-authz/resource-lock' +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' @@ -186,18 +190,47 @@ export async function performUpdateFolder( if (params.parentId !== undefined) updates.parentId = params.parentId || null if (params.sortOrder !== undefined) updates.sortOrder = params.sortOrder - const [updatedFolder] = await db - .update(folder) - .set(updates) - .where( - and( - eq(folder.id, params.folderId), - eq(folder.workspaceId, params.workspaceId), - isWorkflowFolder, - isNull(folder.deletedAt) + 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) + } + + 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 (!updatedFolder) { return { success: false, error: 'Folder not found', errorCode: 'not_found' } @@ -207,6 +240,10 @@ export async function performUpdateFolder( return { success: true, folder: updatedFolder } } catch (error) { + // Propagate uncaught so the route's ResourceLockedError -> 423 handling applies. + if (error instanceof ResourceLockedError) { + throw error + } if (getPostgresErrorCode(error) === '23505') { return { success: false, From f57d28d31b30093b5e4b67661bbe55fffc08b443 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 10 Jul 2026 20:21:38 -0700 Subject: [PATCH 11/15] fix(folders): proactive sweep for missing in-tx lock rechecks + parent-delete race Proactively audited the whole codebase for the two bug classes Greptile has found repeatedly this session (missing in-transaction lock recheck; target parent lock-checked but not re-verified as still active) rather than waiting for another round to find each remaining instance. Found and fixed 9 sites: 1. Greptile P1 (new finding, on last round's own fix): the just-added workflow performUpdateFolder transaction checks the target parent's LOCK state but not whether it's still ACTIVE -- assertFolderMutable's lock walker treats a deleted parent as unlocked (it stops the walk when the row is missing rather than erroring), so a parent deleted between the earlier assertFolderParentValid precheck and this transaction could still have the moved folder's parentId written to point at it. Added a FOR UPDATE + isNull(deletedAt) recheck, race-free once the lock is held. Found and fixed the identical gap in the sibling generic kb/table performUpdateFolder in folders/orchestration.ts, which had the exact same shape from the same earlier round. 2. performDeleteResourceFolder (kb/table folder delete) -- wrapped in a transaction but never checked the folder's own lock state at all before cascading the delete. Added assertFolderMutable inside the tx. 3. deleteKnowledgeBase -- already row-locks via SELECT ... FOR UPDATE but never re-invoked assertResourceMutable after acquiring it. Added the recheck bound to tx. 4. deleteTable -- pre-check only, no transaction and no recheck at all. Wrapped the write in a transaction with an in-tx recheck. 5. archiveWorkspaceFileFolderRecursive (file-folder delete) -- transaction existed but never checked lock state. Added assertFolderMutable inside the tx, matching the sibling restoreWorkspaceFileFolder in the same file. 6. bulkArchiveWorkspaceFileItems (file/folder bulk delete) -- same gap. Since this function already acquires the workspace-scoped pg_advisory_xact_lock as its first step (which every other file-folder mutation in this file also acquires first), it's already fully serialized against concurrent mutations in the same workspace -- unlike the reorder batch fix, a simple per-id recheck loop is safe here without a closure-based ordered lock. 7. renameWorkspaceFile -- no transaction, no recheck. Wrapped in a transaction with the same "unless unlocking" in-tx recheck pattern used at the other lock-toggle-capable rename/update sites. 8. restoreWorkspaceFile -- no lock check anywhere. Added a direct `.locked` check on the already-fetched soft-deleted row (matches the pattern used by the other resource restore paths, since the generic lock engine only reads active rows and can't see a soft-deleted resource's own flag). 9. performDeleteFolder/deleteFolderRecursively (workflow folder delete) -- entirely non-transactional recursive cascade with zero lock checks. archiveWorkflowsByIdsInWorkspace (called mid-cascade) doesn't accept a `tx`, so wrapping the whole recursive cascade in one transaction isn't a clean fix without a larger, riskier change to that shared function. Applied the narrower, still-real improvement: a tightly-scoped transaction around just each folder's own lock recheck + its own delete write, closing the race for that specific row even though the cascade as a whole still isn't one atomic unit. Full local verification passed (typecheck, full vitest suite 11437 tests, check:api-validation, lint all clean, 302 tests across the folders/locking surface specifically). --- apps/sim/lib/folders/orchestration.ts | 119 ++++++++++++------ apps/sim/lib/knowledge/service.ts | 6 + apps/sim/lib/table/service.ts | 26 ++-- .../workspace-file-folder-manager.ts | 18 +++ .../workspace/workspace-file-manager.ts | 58 ++++++--- .../orchestration/folder-lifecycle.ts | 36 +++++- 6 files changed, 201 insertions(+), 62 deletions(-) diff --git a/apps/sim/lib/folders/orchestration.ts b/apps/sim/lib/folders/orchestration.ts index 7be1d34859d..60c2dcb1092 100644 --- a/apps/sim/lib/folders/orchestration.ts +++ b/apps/sim/lib/folders/orchestration.ts @@ -486,6 +486,13 @@ async function performDeleteResourceFolder { - // 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) - } + 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. + const [targetParent] = await tx + .select({ id: folderTable.id }) + .from(folderTable) + .where( + and( + eq(folderTable.id, params.parentId), + eq(folderTable.resourceType, params.resourceType), + isNull(folderTable.deletedAt) + ) + ) + .for('update') + .limit(1) + if (!targetParent) { + throw new FolderParentNotFoundError('Parent folder not found') + } + } - 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) + 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 - }) + .returning() + return row + }) - if (!updated) return { success: false, error: 'Folder not found', errorCode: 'not_found' } - return { success: true, folder: updated } + if (!updated) return { success: false, error: 'Folder not found', errorCode: 'not_found' } + 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' } + } + throw error + } } export async function performDeleteFolder( @@ -839,6 +879,9 @@ export async function performRestoreFolder( 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. */ diff --git a/apps/sim/lib/knowledge/service.ts b/apps/sim/lib/knowledge/service.ts index 2f401c6ae0c..e01c1c9ffa3 100644 --- a/apps/sim/lib/knowledge/service.ts +++ b/apps/sim/lib/knowledge/service.ts @@ -593,6 +593,12 @@ export async function deleteKnowledgeBase( 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({ diff --git a/apps/sim/lib/table/service.ts b/apps/sim/lib/table/service.ts index 99669210c0b..55be5c192a9 100644 --- a/apps/sim/lib/table/service.ts +++ b/apps/sim/lib/table/service.ts @@ -737,15 +737,23 @@ export async function deleteTable( 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 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 94451aa918e..74de6fc5bb4 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 @@ -961,6 +961,13 @@ 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) @@ -1173,6 +1180,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 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 75131a05040..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' @@ -989,23 +993,41 @@ export async function renameWorkspaceFile( } } + const isLockOnlyUpdate = fileRecord.name === normalizedName + let updated: { id: string }[] try { - updated = await db - .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') + 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) @@ -1077,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/orchestration/folder-lifecycle.ts b/apps/sim/lib/workflows/orchestration/folder-lifecycle.ts index 039d76722d4..0c5c7f3bcec 100644 --- a/apps/sim/lib/workflows/orchestration/folder-lifecycle.ts +++ b/apps/sim/lib/workflows/orchestration/folder-lifecycle.ts @@ -22,6 +22,9 @@ 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-update transaction, as a 400 (validation), not a 500. */ +class WorkflowFolderParentNotFoundError extends Error {} + export interface PerformCreateFolderParams { userId: string workspaceId: string @@ -215,6 +218,22 @@ export async function performUpdateFolder( 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') + } } const [row] = await tx @@ -244,6 +263,9 @@ export async function performUpdateFolder( 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, @@ -307,7 +329,19 @@ async function deleteFolderRecursively( stats.workflows += workflowsInFolder.length } - await db.update(folder).set({ deletedAt: timestamp }).where(eq(folder.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 From 9f28f576e6be8b42a0dba255841cec1d1f81112d Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 10 Jul 2026 20:36:16 -0700 Subject: [PATCH 12/15] fix(folders): apply the parent lock/delete-race fix to create paths too Cursor: creating a knowledge_base or table folder inserted after only a pre-request assertMutable on the parent -- the generic update path in the same module already re-checks the parent's lock and active state inside the write transaction, but the create branch never got the same treatment. Extended the proactive sweep from the last round to cover create, since the same two bug classes apply there too: wrapped the kb/table generic performCreateFolder and the workflow-specific performCreateFolder in a transaction, re-checking the target parent's lock state (assertFolderMutable) and, since that lock walker treats a deleted parent as unlocked rather than erroring, a FOR UPDATE + isNull(deletedAt) recheck for active state -- both bound to tx, both race-free once the lock is held. Also added the missing assertFolderMutable check to the file-folder create path (createWorkspaceFileFolder), which already re-verified the target parent's existence/active state under the workspace-scoped advisory lock but never checked its lock state at all. While wiring this up, found and fixed two related gaps the last round's sweep introduced: performCreateFileFolder's and performDeleteFileFolder's catch blocks didn't rethrow ResourceLockedError, so the lock checks added last round (and this round) would have had their exceptions swallowed into a generic 500 instead of the route's 423 handling -- verified every other touched catch block already has this rethrow. Updated the folder-create route test's transaction mock to support the new FOR UPDATE chain. Full local verification passed (typecheck, full vitest suite 11437 tests, check:api-validation, lint all clean, 302 tests across the folders/locking surface). --- apps/sim/app/api/folders/route.test.ts | 18 ++++- apps/sim/lib/folders/orchestration.ts | 74 ++++++++++++++++--- .../workspace-file-folder-manager.ts | 4 + .../orchestration/folder-lifecycle.ts | 56 +++++++++++--- 4 files changed, 125 insertions(+), 27 deletions(-) diff --git a/apps/sim/app/api/folders/route.test.ts b/apps/sim/app/api/folders/route.test.ts index 1e32df7bee3..d62b800ca60 100644 --- a/apps/sim/app/api/folders/route.test.ts +++ b/apps/sim/app/api/folders/route.test.ts @@ -62,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({ @@ -365,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] }], }) ) diff --git a/apps/sim/lib/folders/orchestration.ts b/apps/sim/lib/folders/orchestration.ts index 60c2dcb1092..6e1a9445b04 100644 --- a/apps/sim/lib/folders/orchestration.ts +++ b/apps/sim/lib/folders/orchestration.ts @@ -181,6 +181,10 @@ async function performCreateFileFolder( 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' @@ -278,6 +282,10 @@ async function performDeleteFileFolder( 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' } } @@ -712,20 +720,62 @@ export async function performCreateFolder( }) if (parentError) return { success: false, ...parentError } - const [created] = await db - .insert(folderTable) - .values({ - id: folderId, - resourceType: params.resourceType, - name: params.name.trim(), - userId: params.userId, - workspaceId: params.workspaceId, - parentId, - sortOrder: params.sortOrder ?? 0, + 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. + const [targetParent] = await tx + .select({ id: folderTable.id }) + .from(folderTable) + .where( + and( + eq(folderTable.id, parentId), + eq(folderTable.resourceType, params.resourceType), + isNull(folderTable.deletedAt) + ) + ) + .for('update') + .limit(1) + if (!targetParent) { + throw new FolderParentNotFoundError('Parent folder not found') + } + } + + const [row] = await tx + .insert(folderTable) + .values({ + id: folderId, + resourceType: params.resourceType, + name: params.name.trim(), + userId: params.userId, + workspaceId: params.workspaceId, + parentId, + sortOrder: params.sortOrder ?? 0, + }) + .returning() + return row }) - .returning() - return { success: true, folder: created } + 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' } + } + throw error + } } export async function performUpdateFolder( 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 74de6fc5bb4..f6d7c05b6f8 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 @@ -414,6 +414,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 diff --git a/apps/sim/lib/workflows/orchestration/folder-lifecycle.ts b/apps/sim/lib/workflows/orchestration/folder-lifecycle.ts index 0c5c7f3bcec..e06f80faf99 100644 --- a/apps/sim/lib/workflows/orchestration/folder-lifecycle.ts +++ b/apps/sim/lib/workflows/orchestration/folder-lifecycle.ts @@ -117,18 +117,43 @@ export async function performCreateFolder( ? params.sortOrder : await nextFolderSortOrder(params.workspaceId, parentId) - const [createdFolder] = await db - .insert(folder) - .values({ - id: folderId, - resourceType: 'workflow', - name: params.name.trim(), - userId: params.userId, - workspaceId: params.workspaceId, - parentId, - 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 }) @@ -150,6 +175,13 @@ export async function performCreateFolder( 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, From 93c6995213b9b91b38b7b03486e6d7ecae826284 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 10 Jul 2026 20:47:50 -0700 Subject: [PATCH 13/15] fix(folders): fail safe when the reorder closure goes stale under lock Greptile P1: the cycle-check fix's closure walk discovers ancestors with plain reads before the FOR UPDATE lock is acquired. If a concurrent transaction reparents one of those discovered members to point outside the closure in that gap, the locked read reveals a parentId the cycle walk has no data for. The walk's `?? null` fallback treated "not in closure" the same as "reached root", so it could silently stop short of a real cycle instead of detecting it -- e.g. A moves under B, the locked read shows a concurrently-committed B -> C, and C already points back to A; since C was never discovered by the pre-lock walk, the walk incorrectly terminates at B instead of continuing to C and finding the cycle back to A. A fully general fix would need to lock-and-expand the closure in rounds until it's provably closed under FOR UPDATE, which reintroduces real deadlock-ordering complexity. Took the simpler, still-correct path instead: after acquiring the lock, verify every locked row's parentId is either null or already a member of the locked closure. If not, the closure went stale between discovery and locking -- fail the whole batch (errorCode: conflict, 409) rather than risk running the cycle walk on data it can't fully vouch for. The client can retry, and a second attempt's closure walk will see the now-settled state. Added a regression test reproducing the exact scenario Greptile described. Full local verification passed (typecheck, full vitest suite 11438 tests, check:api-validation, lint all clean, 303 tests across the folders/locking surface). --- .../sim/app/api/folders/reorder/route.test.ts | 45 +++++++++++++++++++ apps/sim/lib/folders/orchestration.ts | 17 +++++++ 2 files changed, 62 insertions(+) diff --git a/apps/sim/app/api/folders/reorder/route.test.ts b/apps/sim/app/api/folders/reorder/route.test.ts index 1d812150f6a..dda02662a46 100644 --- a/apps/sim/app/api/folders/reorder/route.test.ts +++ b/apps/sim/app/api/folders/reorder/route.test.ts @@ -426,6 +426,51 @@ describe('PUT /api/folders/reorder', () => { 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 diff --git a/apps/sim/lib/folders/orchestration.ts b/apps/sim/lib/folders/orchestration.ts index 6e1a9445b04..4366116f590 100644 --- a/apps/sim/lib/folders/orchestration.ts +++ b/apps/sim/lib/folders/orchestration.ts @@ -936,6 +936,8 @@ class FolderParentNotFoundError extends Error {} class FolderReorderNotFoundError extends Error {} /** Marks a cycle caught inside the reorder transaction as a 400, not a 500. */ class FolderReorderCycleError 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 @@ -1078,6 +1080,18 @@ export async function performReorderFolders(params: PerformReorderFoldersParams) 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 @@ -1137,6 +1151,9 @@ export async function performReorderFolders(params: PerformReorderFoldersParams) if (error instanceof FolderReorderCycleError) { 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 }) From 0711c61d684673354d86fe56212a79ee0979b5c4 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 10 Jul 2026 21:54:52 -0700 Subject: [PATCH 14/15] fix(folders): large-scale audit fixes across locking engine and folder orchestration - resource-lock.ts: fix ancestor-walk short-circuit that let a locked ancestor go unnoticed when bypassing a direct lock on the target itself (affects assertFolderMutableUnlessUnlocking/assertResourceMutableUnlessUnlocking) - workflow.ts: thread the inherited flag through WorkflowLockedError (was always false) - folders/orchestration.ts: recheck cycle-freedom inside the transaction for kb/table folder reparents (was only checked against a stale pre-tx read); add missing recordAudit + 409-on-duplicate-name for kb/table folder create/update - workflows/persistence/duplicate.ts: assertTargetFolderMutable now scopes by resourceType='workflow' and row-locks the ancestor chain (was letting a workflow duplicate reparent into a file/kb/table folder) - table/service.ts: wrap renameTable's lock checks and write in a transaction (was running with no transaction and no FOR UPDATE recheck) - workflow-lifecycle.ts: wrap performUpdateWorkflow in a transaction with an in-tx lock recheck (was relying solely on a non-transactional route pre-check) - knowledge/service.ts: recheck updateKnowledgeBase's lock inside the transaction after acquiring the row lock - workflows/orchestration/folder-lifecycle.ts: check the target folder's own lock before cascading into children (was checking only after) - workspace-files/orchestration/file-folder-lifecycle.ts: propagate the real errorCode from performRestoreFolder instead of hardcoding not_found - uploads/contexts/workspace/workspace-file-folder-manager.ts: fix missing import of assertFolderMutableUnlessUnlocking (broke typecheck) - hooks/queries/folders.ts: invalidate the correct resource-type-scoped list on folder delete/restore instead of always invalidating workflow lists - knowledge.tsx: folder rows now honor the active sort column/direction - background/cleanup-soft-deletes.ts: hard-delete retention now covers file/table/knowledge_base folders, not just workflow folders - workspace-vfs.ts: surface archived table/knowledge_base folders under recently-deleted/ so they can be discovered and restored - table API routes: include folderId/locked in create and detail responses - schema.mock.ts: add missing folderId/locked to workspaceFiles/knowledgeBase/ userTableDefinitions mocks - add regression test coverage for the resource-lock ancestor-walk fix and update table service tests for the new transactional renameTable call shape --- apps/sim/app/api/table/[tableId]/route.ts | 1 + apps/sim/app/api/table/route.ts | 2 + .../[workspaceId]/knowledge/knowledge.tsx | 16 ++- apps/sim/background/cleanup-soft-deletes.ts | 27 ++++- apps/sim/hooks/queries/folders.ts | 40 ++++++- apps/sim/lib/copilot/vfs/workspace-vfs.ts | 68 +++++++++++ apps/sim/lib/folders/orchestration.ts | 59 ++++++++- apps/sim/lib/folders/resource-lock.test.ts | 87 ++++++++++++++ apps/sim/lib/knowledge/service.ts | 15 +++ apps/sim/lib/table/__tests__/service.test.ts | 27 ++++- apps/sim/lib/table/service.ts | 112 ++++++++++-------- .../workspace-file-folder-manager.ts | 1 + .../orchestration/folder-lifecycle.ts | 7 ++ .../orchestration/workflow-lifecycle.ts | 88 +++++++++++--- .../lib/workflows/persistence/duplicate.ts | 16 ++- .../orchestration/file-folder-lifecycle.ts | 2 +- packages/platform-authz/src/resource-lock.ts | 49 ++++++-- packages/platform-authz/src/workflow.ts | 7 +- packages/testing/src/mocks/schema.mock.ts | 6 + 19 files changed, 534 insertions(+), 96 deletions(-) diff --git a/apps/sim/app/api/table/[tableId]/route.ts b/apps/sim/app/api/table/[tableId]/route.ts index e9a76aa0d32..e3eb73e70df 100644 --- a/apps/sim/app/api/table/[tableId]/route.ts +++ b/apps/sim/app/api/table/[tableId]/route.ts @@ -67,6 +67,7 @@ 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 diff --git a/apps/sim/app/api/table/route.ts b/apps/sim/app/api/table/route.ts index 2a1a107ad25..8787701b8f8 100644 --- a/apps/sim/app/api/table/route.ts +++ b/apps/sim/app/api/table/route.ts @@ -129,6 +129,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() diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx index 2f99e988c9d..ca3a9f26f1b 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx @@ -324,8 +324,20 @@ export function Knowledge() { folder.name.toLowerCase().includes(debouncedSearchQuery.toLowerCase()) ) : siblings - return [...searched].sort((a, b) => a.name.localeCompare(b.name)) - }, [folders, currentFolderId, debouncedSearchQuery]) + 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).filter( diff --git a/apps/sim/background/cleanup-soft-deletes.ts b/apps/sim/background/cleanup-soft-deletes.ts index ad03927296b..95217ca3292 100644 --- a/apps/sim/background/cleanup-soft-deletes.ts +++ b/apps/sim/background/cleanup-soft-deletes.ts @@ -139,11 +139,34 @@ const CLEANUP_TARGETS = [ table: workflowFolder, softDeleteCol: workflowFolder.deletedAt, wsCol: workflowFolder.workspaceId, - // `folder` is now a polymorphic table shared with file/knowledge_base/table - // folders — scope hard-delete to workflow folders only. + // `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, diff --git a/apps/sim/hooks/queries/folders.ts b/apps/sim/hooks/queries/folders.ts index c1dba3b239d..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 { @@ -13,6 +14,7 @@ import { 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' @@ -20,10 +22,36 @@ 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 { 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 @@ -268,7 +296,11 @@ 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 + ) }, }) } @@ -291,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 + ) }, }) } diff --git a/apps/sim/lib/copilot/vfs/workspace-vfs.ts b/apps/sim/lib/copilot/vfs/workspace-vfs.ts index ebb3a9e7a6d..488ae690952 100644 --- a/apps/sim/lib/copilot/vfs/workspace-vfs.ts +++ b/apps/sim/lib/copilot/vfs/workspace-vfs.ts @@ -2235,6 +2235,8 @@ export class WorkspaceVFS { archivedFiles, archivedFileFolders, archivedKBs, + archivedTableFolders, + archivedKnowledgeBaseFolders, ] = await Promise.all([ listWorkflows(workspaceId, { scope: 'archived' }), db @@ -2255,6 +2257,38 @@ export class WorkspaceVFS { 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) { @@ -2294,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 index 4366116f590..bf2a8e88006 100644 --- a/apps/sim/lib/folders/orchestration.ts +++ b/apps/sim/lib/folders/orchestration.ts @@ -765,6 +765,16 @@ export async function performCreateFolder( 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. @@ -774,6 +784,13 @@ export async function performCreateFolder( 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 } } @@ -862,6 +879,21 @@ export async function performUpdateFolder( if (!targetParent) { throw new FolderParentNotFoundError('Parent folder not found') } + + // 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 @@ -880,6 +912,17 @@ export async function performUpdateFolder( }) 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. @@ -889,6 +932,16 @@ export async function performUpdateFolder( 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 } } @@ -935,7 +988,7 @@ 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 FolderReorderCycleError extends Error {} +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 {} @@ -1111,7 +1164,7 @@ export async function performReorderFolders(params: PerformReorderFoldersParams) let cursor: string | null = update.id while (cursor) { if (visited.has(cursor)) { - throw new FolderReorderCycleError('Cannot create circular folder reference') + throw new FolderCycleError('Cannot create circular folder reference') } visited.add(cursor) cursor = lockedParentById.get(cursor) ?? null @@ -1148,7 +1201,7 @@ export async function performReorderFolders(params: PerformReorderFoldersParams) if (error instanceof FolderReorderNotFoundError) { return { success: false, updated: 0, error: error.message, errorCode: 'not_found' } } - if (error instanceof FolderReorderCycleError) { + if (error instanceof FolderCycleError) { return { success: false, updated: 0, error: error.message, errorCode: 'validation' } } if (error instanceof FolderReorderStaleClosureError) { diff --git a/apps/sim/lib/folders/resource-lock.test.ts b/apps/sim/lib/folders/resource-lock.test.ts index 75687120f0a..15b11b542f2 100644 --- a/apps/sim/lib/folders/resource-lock.test.ts +++ b/apps/sim/lib/folders/resource-lock.test.ts @@ -42,7 +42,9 @@ vi.mock('drizzle-orm', () => ({ import { assertFolderMutable, + assertFolderMutableUnlessUnlocking, assertResourceMutable, + assertResourceMutableUnlessUnlocking, type FolderResourceType, getFolderLockStatus, getResourceLockStatus, @@ -128,6 +130,91 @@ describe('resource-lock engine', () => { 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() + }) }) }) diff --git a/apps/sim/lib/knowledge/service.ts b/apps/sim/lib/knowledge/service.ts index e01c1c9ffa3..435b4aee3fe 100644 --- a/apps/sim/lib/knowledge/service.ts +++ b/apps/sim/lib/knowledge/service.ts @@ -361,6 +361,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 diff --git a/apps/sim/lib/table/__tests__/service.test.ts b/apps/sim/lib/table/__tests__/service.test.ts index 517f848623a..948d8677483 100644 --- a/apps/sim/lib/table/__tests__/service.test.ts +++ b/apps/sim/lib/table/__tests__/service.test.ts @@ -43,7 +43,11 @@ describe('table service — resource-lock enforcement', () => { inherited: false, }) - expect(resourceLockMockFns.mockAssertResourceMutable).toHaveBeenCalledWith('table', 'tbl-1') + expect(resourceLockMockFns.mockAssertResourceMutable).toHaveBeenCalledWith( + 'table', + 'tbl-1', + expect.anything() + ) expect(dbChainMockFns.update).not.toHaveBeenCalled() }) @@ -73,7 +77,11 @@ describe('table service — resource-lock enforcement', () => { await renameTable('tbl-1', 'new_name', 'req-1') - expect(resourceLockMockFns.mockAssertResourceMutable).toHaveBeenCalledWith('table', 'tbl-1') + expect(resourceLockMockFns.mockAssertResourceMutable).toHaveBeenCalledWith( + 'table', + 'tbl-1', + expect.anything() + ) expect(dbChainMockFns.update).toHaveBeenCalledTimes(1) }) @@ -118,8 +126,16 @@ describe('table service — resource-lock enforcement', () => { await renameTable('tbl-1', 'new_name', 'req-1', undefined, 'folder-1', false, false) - expect(resourceLockMockFns.mockAssertResourceMutable).toHaveBeenCalledWith('table', 'tbl-1') - expect(resourceLockMockFns.mockAssertFolderMutable).toHaveBeenCalledWith('folder-1', 'table') + expect(resourceLockMockFns.mockAssertResourceMutable).toHaveBeenCalledWith( + 'table', + 'tbl-1', + expect.anything() + ) + expect(resourceLockMockFns.mockAssertFolderMutable).toHaveBeenCalledWith( + 'folder-1', + 'table', + expect.anything() + ) expect(dbChainMockFns.update).toHaveBeenCalledTimes(1) }) @@ -154,7 +170,8 @@ describe('table service — resource-lock enforcement', () => { expect(resourceLockMockFns.mockAssertFolderMutable).toHaveBeenCalledWith( 'folder-locked', - 'table' + 'table', + expect.anything() ) expect(dbChainMockFns.update).not.toHaveBeenCalled() }) diff --git a/apps/sim/lib/table/service.ts b/apps/sim/lib/table/service.ts index 55be5c192a9..33c759f4cfc 100644 --- a/apps/sim/lib/table/service.ts +++ b/apps/sim/lib/table/service.ts @@ -579,61 +579,71 @@ export async function renameTable( throw new Error(nameValidation.errors.join(', ')) } - // `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) - } - - if (folderId) { - const [tableRow] = await db - .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', - }) - if (parentError) { - throw new Error(`Invalid folderId: ${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') - } - const now = new Date() try { - const result = await db - .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, - }) + 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 Error(`Invalid folderId: ${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, folderId: updatedFolderId, locked: updatedLocked } = result[0] + const { createdBy, workspaceId, folderId: updatedFolderId, locked: updatedLocked } = result const renameActorId = actingUserId ?? createdBy if (renameActorId) { recordAudit({ 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 f6d7c05b6f8..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 @@ -3,6 +3,7 @@ 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' diff --git a/apps/sim/lib/workflows/orchestration/folder-lifecycle.ts b/apps/sim/lib/workflows/orchestration/folder-lifecycle.ts index e06f80faf99..0915828059a 100644 --- a/apps/sim/lib/workflows/orchestration/folder-lifecycle.ts +++ b/apps/sim/lib/workflows/orchestration/folder-lifecycle.ts @@ -323,6 +323,13 @@ async function deleteFolderRecursively( 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: folder.id }) .from(folder) diff --git a/apps/sim/lib/workflows/orchestration/workflow-lifecycle.ts b/apps/sim/lib/workflows/orchestration/workflow-lifecycle.ts index 0c22368d614..cb56fe411ef 100644 --- a/apps/sim/lib/workflows/orchestration/workflow-lifecycle.ts +++ b/apps/sim/lib/workflows/orchestration/workflow-lifecycle.ts @@ -2,7 +2,16 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' 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' @@ -320,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' } @@ -372,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 936428263ea..7b3169dfbe0 100644 --- a/apps/sim/lib/workflows/persistence/duplicate.ts +++ b/apps/sim/lib/workflows/persistence/duplicate.ts @@ -67,10 +67,15 @@ 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, @@ -80,16 +85,23 @@ async function assertTargetFolderMutable( archivedAt: 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) { 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 } } 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 32faa0804cb..e89fb90da0d 100644 --- a/apps/sim/lib/workspace-files/orchestration/file-folder-lifecycle.ts +++ b/apps/sim/lib/workspace-files/orchestration/file-folder-lifecycle.ts @@ -451,7 +451,7 @@ export async function performRestoreWorkspaceFileFolder( const result = await performRestoreFolder({ resourceType: 'file', workspaceId, folderId, userId }) if (!result.success || !result.restoredItems) { - return { success: false, error: result.error, errorCode: 'not_found' } + return { success: false, error: result.error, errorCode: result.errorCode } } const folder = await getWorkspaceFileFolder(workspaceId, folderId) diff --git a/packages/platform-authz/src/resource-lock.ts b/packages/platform-authz/src/resource-lock.ts index 8c673c52963..5ea6af10620 100644 --- a/packages/platform-authz/src/resource-lock.ts +++ b/packages/platform-authz/src/resource-lock.ts @@ -114,6 +114,7 @@ export async function getFolderLockStatus( 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) @@ -142,12 +143,25 @@ export async function getFolderLockStatus( if (!folderRow) break if (folderRow.locked) { - return { - locked: true, - directLocked: isDirect, - inheritedLocked: !isDirect, - lockedBy: 'folder', - lockedFolderId: folderRow.id, + 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, + } } } @@ -155,7 +169,7 @@ export async function getFolderLockStatus( isDirect = false } - return UNLOCKED_STATUS + return directLock ?? UNLOCKED_STATUS } /** @@ -183,6 +197,25 @@ export async function getResourceLockStatus( 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, @@ -193,7 +226,7 @@ export async function getResourceLockStatus( } } - return getFolderLockStatus(row.folderId as string | null, resourceType, dbClient) + return UNLOCKED_STATUS } export async function assertFolderMutable( diff --git a/packages/platform-authz/src/workflow.ts b/packages/platform-authz/src/workflow.ts index b650239448a..68ff4b57893 100644 --- a/packages/platform-authz/src/workflow.ts +++ b/packages/platform-authz/src/workflow.ts @@ -73,8 +73,8 @@ type WorkflowRecord = typeof workflow.$inferSelect * `resource-lock.ts`. */ export class WorkflowLockedError extends ResourceLockedError { - constructor(message = 'Workflow is locked') { - super('workflow', false, message) + constructor(message = 'Workflow is locked', inherited = false) { + super('workflow', inherited, message) this.name = 'WorkflowLockedError' } } @@ -156,7 +156,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' ) } } diff --git a/packages/testing/src/mocks/schema.mock.ts b/packages/testing/src/mocks/schema.mock.ts index 29ae4d249df..70a81b05c04 100644 --- a/packages/testing/src/mocks/schema.mock.ts +++ b/packages/testing/src/mocks/schema.mock.ts @@ -551,6 +551,8 @@ export const schemaMock = { originalName: 'originalName', contentType: 'contentType', size: 'size', + folderId: 'folderId', + locked: 'locked', deletedAt: 'deletedAt', uploadedAt: 'uploadedAt', }, @@ -598,6 +600,8 @@ export const schemaMock = { embeddingModel: 'embeddingModel', embeddingDimension: 'embeddingDimension', chunkingConfig: 'chunkingConfig', + folderId: 'folderId', + locked: 'locked', deletedAt: 'deletedAt', createdAt: 'createdAt', updatedAt: 'updatedAt', @@ -1057,6 +1061,8 @@ export const schemaMock = { metadata: 'metadata', maxRows: 'maxRows', rowCount: 'rowCount', + folderId: 'folderId', + locked: 'locked', archivedAt: 'archivedAt', createdBy: 'createdBy', createdAt: 'createdAt', From 06b7898af2643beebd1b910d2912d4d8b7ad8c54 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 10 Jul 2026 22:23:16 -0700 Subject: [PATCH 15/15] fix(folders): cleanup pass + fix stale cycle-check race in workflow folder update Cleanup pass over the remaining audit findings (dead code, stale comments, duplicated logic, minor hygiene), plus two real bugs caught along the way: - workflow folder-lifecycle.ts: performUpdateFolder had the same stale cycle-check race already fixed in the generic kb/table path -- the circular-reference check ran against an unlocked pre-transaction snapshot, so two concurrent moves (A under B, B under A) could each pass and commit a persisted cycle. Re-check inside the transaction now, matching the generic path. (Caught by Greptile on the post-audit re-review.) - folders/orchestration.ts: performUpdateFileFolder was swallowing ResourceLockedError into a generic 500 instead of propagating it for the route's 423 handling, unlike its create/delete/restore siblings. - knowledge/[id]/route.ts: the admin-lock-permission check for PUT only verified admin on the KB's *current* workspace, even though workspaceId and locked can change in the same request -- now checks both current and target workspace. Other fixes: extracted duplicated lock+recheck-parent logic in folders/orchestration.ts into a shared helper; removed dead `validUpdates` aliases; removed dead `color` field from workflow folder params; fixed stale comments claiming `locked`/file-folder locking is workflow-only or unused (the generic lock engine treats all four resourceTypes uniformly); added resourceType scoping to log folder-descendant expansion; fixed an O(n*m) descendant-collection loop in the files-download route to build its parent->children index once; added typed TableInvalidFolderError instead of string-matching error messages; fixed a hardcoded SQL column literal in pinned-items lookups; deduplicated FolderResourceType between the folders contract and the folders store; derived a magic query-key array index from the key factory instead of a literal; moved a render-time ref mutation in files.tsx into an effect; memoized a per-render Object.fromEntries in knowledge.tsx; fixed recently-deleted.tsx passing no resourceType on file folder restore (silently defaulted to workflow); aligned a stray eslint-disable in tables.tsx with its file/knowledge siblings. Fixed a knowledge-route test whose hand-rolled db mock had no `transaction` stub (needed after createKnowledgeBase was wrapped in a transaction to close its own folder-lock TOCTOU window) and whose beforeEach was clobbering that stub every test. --- apps/sim/app/api/folders/reorder/route.ts | 18 +-- apps/sim/app/api/folders/route.ts | 10 +- apps/sim/app/api/knowledge/[id]/route.ts | 22 +-- apps/sim/app/api/knowledge/route.test.ts | 13 +- apps/sim/app/api/pinned-items/route.ts | 4 +- apps/sim/app/api/table/[tableId]/route.ts | 10 +- apps/sim/app/api/table/route.ts | 6 +- .../workspaces/[id]/files/download/route.ts | 33 ++-- .../workspace/[workspaceId]/files/files.tsx | 9 +- .../[workspaceId]/knowledge/knowledge.tsx | 5 +- .../recently-deleted/recently-deleted.tsx | 1 + .../workspace/[workspaceId]/tables/tables.tsx | 3 +- apps/sim/hooks/queries/pinned-items.ts | 9 +- .../sim/hooks/use-folder-create-with-dedup.ts | 3 +- apps/sim/lib/folders/orchestration.ts | 143 +++++++++++++----- apps/sim/lib/knowledge/service.ts | 18 ++- apps/sim/lib/logs/folder-expansion.ts | 8 +- apps/sim/lib/table/service.ts | 11 +- .../orchestration/folder-lifecycle.ts | 23 ++- .../lib/workflows/persistence/duplicate.ts | 4 +- apps/sim/stores/folders/types.ts | 2 +- packages/db/schema.ts | 10 +- packages/platform-authz/src/resource-lock.ts | 45 +++--- packages/platform-authz/src/workflow.ts | 11 +- 24 files changed, 288 insertions(+), 133 deletions(-) diff --git a/apps/sim/app/api/folders/reorder/route.ts b/apps/sim/app/api/folders/reorder/route.ts index 3776aa44a7a..b0f7062b968 100644 --- a/apps/sim/app/api/folders/reorder/route.ts +++ b/apps/sim/app/api/folders/reorder/route.ts @@ -59,17 +59,13 @@ export const PUT = withRouteHandler(async (req: NextRequest) => { if (hasInvalidId) { return NextResponse.json({ error: 'One or more folders were not found' }, { status: 400 }) } - const validUpdates = updates - // 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(validUpdates[0].id)! - const hasMixedResourceTypes = validUpdates.some( - (u) => resourceTypeById.get(u.id) !== resourceType - ) + 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' }, @@ -81,7 +77,7 @@ export const PUT = withRouteHandler(async (req: NextRequest) => { // `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 validUpdates) { + for (const update of updates) { if (update.parentId && update.parentId === update.id) { return NextResponse.json({ error: 'Folder cannot be its own parent' }, { status: 400 }) } @@ -102,13 +98,13 @@ export const PUT = withRouteHandler(async (req: NextRequest) => { 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) { @@ -124,7 +120,7 @@ export const PUT = withRouteHandler(async (req: NextRequest) => { } const policy = FOLDER_RESOURCE_POLICIES[resourceType] - for (const update of validUpdates) { + for (const update of updates) { await policy.assertMutable(update.id) if (update.parentId !== undefined) { await policy.assertMutable(update.parentId) @@ -134,7 +130,7 @@ export const PUT = withRouteHandler(async (req: NextRequest) => { const result = await performReorderFolders({ resourceType, workspaceId, - updates: validUpdates, + updates, }) if (!result.success) { diff --git a/apps/sim/app/api/folders/route.ts b/apps/sim/app/api/folders/route.ts index 38897b79c99..f7eaf39db27 100644 --- a/apps/sim/app/api/folders/route.ts +++ b/apps/sim/app/api/folders/route.ts @@ -9,17 +9,11 @@ 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 { 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 -} - export const GET = withRouteHandler(async (request: NextRequest) => { try { const session = await getSession() @@ -96,7 +90,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { if (!result.success || !result.folder) { return NextResponse.json( { error: result.error }, - { status: folderMutationStatus(result.errorCode) } + { status: statusForOrchestrationError(result.errorCode) } ) } diff --git a/apps/sim/app/api/knowledge/[id]/route.ts b/apps/sim/app/api/knowledge/[id]/route.ts index 1876d566e07..4ef1e99f8cb 100644 --- a/apps/sim/app/api/knowledge/[id]/route.ts +++ b/apps/sim/app/api/knowledge/[id]/route.ts @@ -98,15 +98,19 @@ export const PUT = withRouteHandler( const validatedData = parsed.data.body if (validatedData.locked !== undefined) { - const workspaceId = accessCheck.knowledgeBase.workspaceId - const workspacePermission = workspaceId - ? await getUserEntityPermissions(userId, 'workspace', workspaceId) - : null - if (workspacePermission !== 'admin') { - return NextResponse.json( - { error: 'Admin access required to lock knowledge bases' }, - { status: 403 } - ) + 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 } + ) + } } } 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/pinned-items/route.ts b/apps/sim/app/api/pinned-items/route.ts index 5ea3f44ec56..17ae01362ab 100644 --- a/apps/sim/app/api/pinned-items/route.ts +++ b/apps/sim/app/api/pinned-items/route.ts @@ -10,7 +10,7 @@ import { import { createLogger } from '@sim/logger' import { getPostgresErrorCode } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' -import { and, eq, inArray, isNull, sql } from 'drizzle-orm' +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' @@ -82,7 +82,7 @@ async function resourceExistsInWorkspace( ): Promise { const config = PINNED_RESOURCE_LOOKUP[resourceType] const [row] = await db - .select({ id: sql`id` }) + .select({ id: config.idColumn }) .from(config.resourceTable) .where( and( diff --git a/apps/sim/app/api/table/[tableId]/route.ts b/apps/sim/app/api/table/[tableId]/route.ts index e3eb73e70df..0a587d3fab4 100644 --- a/apps/sim/app/api/table/[tableId]/route.ts +++ b/apps/sim/app/api/table/[tableId]/route.ts @@ -8,7 +8,13 @@ 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' @@ -168,7 +174,7 @@ export const PATCH = withRouteHandler( if (error instanceof TableConflictError) { return NextResponse.json({ error: error.message }, { status: 409 }) } - if (error instanceof Error && error.message.includes('Invalid folderId')) { + if (error instanceof TableInvalidFolderError) { return NextResponse.json({ error: error.message }, { status: 400 }) } diff --git a/apps/sim/app/api/table/route.ts b/apps/sim/app/api/table/route.ts index 8787701b8f8..3d96035da27 100644 --- a/apps/sim/app/api/table/route.ts +++ b/apps/sim/app/api/table/route.ts @@ -12,6 +12,7 @@ import { createTable, getWorkspaceTableLimits, listTables, + TableInvalidFolderError, type TableSchema, type TableScope, } from '@/lib/table' @@ -148,6 +149,10 @@ export const POST = withRouteHandler(async (request: NextRequest) => { 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 }) @@ -155,7 +160,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { if ( error.message.includes('Invalid table name') || error.message.includes('Invalid schema') || - error.message.includes('Invalid folderId') || error.message.includes('already exists') ) { return NextResponse.json({ error: error.message }, { status: 400 }) 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 998147022e3..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,7 +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 { collectDescendantFolderIds } from '@/lib/folders/subtree' +import type { FolderSubtreeRow } from '@/lib/folders/subtree' import { captureServerEvent } from '@/lib/posthog/server' import { buildWorkspaceFileFolderPathMap, @@ -43,17 +43,32 @@ function withZipPathSuffix(path: string, suffix: number): string { : `${directory}${filename} (${suffix})` } -/** Unions each root folder id with every descendant reachable from it. */ -function collectSelectedFolderIds( - rootIds: string[], - folders: Array<{ id: string; parentId: string | null }> -): Set { +/** + * 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) - for (const rootId of rootIds) { - for (const descendantId of collectDescendantFolderIds(folders, rootId)) { - folderIds.add(descendantId) + 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 } diff --git a/apps/sim/app/workspace/[workspaceId]/files/files.tsx b/apps/sim/app/workspace/[workspaceId]/files/files.tsx index 5f5f8ec3990..d4676e540eb 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/files.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/files.tsx @@ -599,7 +599,6 @@ export function Files() { const [prevVisibleRowIds, setPrevVisibleRowIds] = useState(visibleRowIds) if (prevVisibleRowIds !== visibleRowIds) { setPrevVisibleRowIds(visibleRowIds) - lastSelectedIndexRef.current = -1 const visible = new Set(visibleRowIds) setSelectedRowIds((prev) => { if (prev.size === 0) return prev @@ -608,6 +607,14 @@ export function Files() { }) } + // 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 = visibleRowIds.length > 0 && visibleRowIds.every((id) => selectedRowIds.has(id)) const { selectedFileIds, selectedFolderIds } = useMemo(() => { diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx index ca3a9f26f1b..7e0db809b66 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx @@ -314,6 +314,7 @@ export function Knowledge() { ) 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 @@ -605,7 +606,7 @@ export function Knowledge() { const knowledgeBaseInheritedLocked = isFolderOrAncestorLocked( activeKnowledgeBase?.folderId ?? null, - Object.fromEntries(folderById) + folderByIdRecord ) const handleToggleKnowledgeBaseLock = useCallback(() => { @@ -636,7 +637,7 @@ export function Knowledge() { const folderInheritedLocked = isFolderOrAncestorLocked( activeFolder?.parentId ?? null, - Object.fromEntries(folderById) + folderByIdRecord ) const handleToggleFolderLock = useCallback(() => { 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 751c553c327..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 @@ -437,6 +437,7 @@ export function RecentlyDeleted() { await restoreFolder.mutateAsync({ workspaceId: resource.workspaceId, folderId: resource.id, + resourceType: 'file', }) break case 'table_folder': diff --git a/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx b/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx index 35f1e62d700..9c732079668 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx @@ -863,8 +863,7 @@ export function Tables() { (folder: FolderType) => { listRename.startRename(folderRowId(folder.id), folder.name) }, - // eslint-disable-next-line react-hooks/exhaustive-deps -- listRename.startRename is stable - [] + [listRename.startRename] ) const handleCreateFolder = useFolderCreateWithDedup({ diff --git a/apps/sim/hooks/queries/pinned-items.ts b/apps/sim/hooks/queries/pinned-items.ts index cb155b3acbb..8d4bb6c2a97 100644 --- a/apps/sim/hooks/queries/pinned-items.ts +++ b/apps/sim/hooks/queries/pinned-items.ts @@ -78,6 +78,13 @@ interface UnpinItemVariables { 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 @@ -85,7 +92,7 @@ interface UnpinItemVariables { * types untouched. */ function isAffectedPinnedListKey(queryKey: QueryKey, resourceType: PinnedResourceType): boolean { - const scopedType = queryKey[3] + const scopedType = queryKey[RESOURCE_TYPE_KEY_INDEX] return scopedType === '' || scopedType === resourceType } diff --git a/apps/sim/hooks/use-folder-create-with-dedup.ts b/apps/sim/hooks/use-folder-create-with-dedup.ts index 776c0e89f92..7da68e29265 100644 --- a/apps/sim/hooks/use-folder-create-with-dedup.ts +++ b/apps/sim/hooks/use-folder-create-with-dedup.ts @@ -2,8 +2,9 @@ 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, FolderResourceType } from '@/stores/folders/types' +import type { Folder } from '@/stores/folders/types' const logger = createLogger('useFolderCreateWithDedup') diff --git a/apps/sim/lib/folders/orchestration.ts b/apps/sim/lib/folders/orchestration.ts index bf2a8e88006..8ee05142bec 100644 --- a/apps/sim/lib/folders/orchestration.ts +++ b/apps/sim/lib/folders/orchestration.ts @@ -9,7 +9,7 @@ import { } from '@sim/platform-authz/resource-lock' import { getPostgresErrorCode, toError } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' -import { and, eq, inArray, isNull } from 'drizzle-orm' +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 { @@ -117,9 +117,9 @@ export interface PerformReorderFoldersParams { * 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 reserved-but-unused for `file` folders (see - * `packages/db/schema.ts`), so we mirror the table's own default here - * rather than round-tripping to re-select it. + * `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 { @@ -235,6 +235,10 @@ async function performUpdateFileFolder( 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' @@ -503,6 +507,28 @@ async function performDeleteResourceFolder 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 }) @@ -699,6 +725,63 @@ async function performRestoreResourceFolder { + 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 { @@ -720,6 +803,11 @@ export async function performCreateFolder( }) 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) { @@ -733,21 +821,7 @@ export async function performCreateFolder( // (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: folderTable.id }) - .from(folderTable) - .where( - and( - eq(folderTable.id, parentId), - eq(folderTable.resourceType, params.resourceType), - isNull(folderTable.deletedAt) - ) - ) - .for('update') - .limit(1) - if (!targetParent) { - throw new FolderParentNotFoundError('Parent folder not found') - } + await assertTargetParentActive(tx, parentId, params.resourceType) } const [row] = await tx @@ -759,7 +833,7 @@ export async function performCreateFolder( userId: params.userId, workspaceId: params.workspaceId, parentId, - sortOrder: params.sortOrder ?? 0, + sortOrder, }) .returning() return row @@ -864,21 +938,7 @@ export async function performUpdateFolder( // 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: folderTable.id }) - .from(folderTable) - .where( - and( - eq(folderTable.id, params.parentId), - eq(folderTable.resourceType, params.resourceType), - isNull(folderTable.deletedAt) - ) - ) - .for('update') - .limit(1) - if (!targetParent) { - throw new FolderParentNotFoundError('Parent folder not found') - } + 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 @@ -1028,7 +1088,6 @@ export async function performReorderFolders(params: PerformReorderFoldersParams) errorCode: 'not_found', } } - const validUpdates = updates // Reparents also need `assertFolderParentValid` on the new parent (the // `validIds` check above only validates `id`). Any invalid parentId fails @@ -1036,7 +1095,7 @@ export async function performReorderFolders(params: PerformReorderFoldersParams) // Distinct target parentIds are validated concurrently since a subtree // drag-drop can reparent many folders in one call. const targetParentIds = Array.from( - new Set(validUpdates.map((u) => u.parentId).filter((id): id is string => Boolean(id))) + new Set(updates.map((u) => u.parentId).filter((id): id is string => Boolean(id))) ) const parentErrors = await Promise.all( targetParentIds.map((parentId) => @@ -1071,7 +1130,7 @@ export async function performReorderFolders(params: PerformReorderFoldersParams) // 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([...validUpdates.map((u) => u.id), ...targetParentIds])) + 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) { @@ -1154,12 +1213,12 @@ export async function performReorderFolders(params: PerformReorderFoldersParams) // 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 validUpdates) { + for (const update of updates) { if (update.parentId !== undefined) { lockedParentById.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) { @@ -1171,7 +1230,7 @@ export async function performReorderFolders(params: PerformReorderFoldersParams) } } - for (const update of validUpdates) { + for (const update of updates) { const updateData: Record = { sortOrder: update.sortOrder, updatedAt: new Date(), @@ -1218,5 +1277,5 @@ export async function performReorderFolders(params: PerformReorderFoldersParams) } } - return { success: true, updated: validUpdates.length } + return { success: true, updated: updates.length } } diff --git a/apps/sim/lib/knowledge/service.ts b/apps/sim/lib/knowledge/service.ts index 435b4aee3fe..88ef81900e0 100644 --- a/apps/sim/lib/knowledge/service.ts +++ b/apps/sim/lib/knowledge/service.ts @@ -202,11 +202,6 @@ export async function createKnowledgeBase( if (parentError) { throw new KnowledgeBaseValidationError(parentError.error) } - // assertFolderParentValid above only checks workspace/type/deleted-state -- without - // this, a knowledge base could be created directly inside a locked folder. - if (data.folderId) { - await assertFolderMutable(data.folderId, 'knowledge_base') - } if (duplicate.length > 0) { throw new KnowledgeBaseConflictError(data.name) } @@ -228,8 +223,19 @@ export async function createKnowledgeBase( deletedAt: null, } + // 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) diff --git a/apps/sim/lib/logs/folder-expansion.ts b/apps/sim/lib/logs/folder-expansion.ts index 4d0b607ad32..2e132eefa6a 100644 --- a/apps/sim/lib/logs/folder-expansion.ts +++ b/apps/sim/lib/logs/folder-expansion.ts @@ -25,7 +25,13 @@ export async function expandFolderIdsWithDescendants( const rows = await db .select({ id: folder.id, parentId: folder.parentId }) .from(folder) - .where(and(eq(folder.workspaceId, workspaceId), isNull(folder.deletedAt))) + .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/table/service.ts b/apps/sim/lib/table/service.ts index 33c759f4cfc..7d844f1b05d 100644 --- a/apps/sim/lib/table/service.ts +++ b/apps/sim/lib/table/service.ts @@ -47,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' /** @@ -320,7 +327,7 @@ export async function createTable( ]) if (parentError) { - throw new Error(`Invalid folderId: ${parentError.error}`) + throw new TableInvalidFolderError(parentError.error) } // Wrap count check, duplicate check, and insert in a transaction with FOR UPDATE @@ -613,7 +620,7 @@ export async function renameTable( trx ) if (parentError) { - throw new Error(`Invalid folderId: ${parentError.error}`) + 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. diff --git a/apps/sim/lib/workflows/orchestration/folder-lifecycle.ts b/apps/sim/lib/workflows/orchestration/folder-lifecycle.ts index 0915828059a..4b9bad1d98c 100644 --- a/apps/sim/lib/workflows/orchestration/folder-lifecycle.ts +++ b/apps/sim/lib/workflows/orchestration/folder-lifecycle.ts @@ -22,8 +22,10 @@ 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-update transaction, as a 400 (validation), not a 500. */ +/** 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 @@ -31,7 +33,6 @@ export interface PerformCreateFolderParams { name: string id?: string parentId?: string | null - color?: string sortOrder?: number } @@ -266,6 +267,21 @@ export async function performUpdateFolder( 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 @@ -298,6 +314,9 @@ export async function performUpdateFolder( 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, diff --git a/apps/sim/lib/workflows/persistence/duplicate.ts b/apps/sim/lib/workflows/persistence/duplicate.ts index 7b3169dfbe0..9b8eb5230d7 100644 --- a/apps/sim/lib/workflows/persistence/duplicate.ts +++ b/apps/sim/lib/workflows/persistence/duplicate.ts @@ -82,7 +82,7 @@ async function assertTargetFolderMutable( parentId: workflowFolder.parentId, workspaceId: workflowFolder.workspaceId, locked: workflowFolder.locked, - archivedAt: workflowFolder.deletedAt, + deletedAt: workflowFolder.deletedAt, }) .from(workflowFolder) .where( @@ -91,7 +91,7 @@ async function assertTargetFolderMutable( .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) { diff --git a/apps/sim/stores/folders/types.ts b/apps/sim/stores/folders/types.ts index e4073c6a472..18adccf223b 100644 --- a/apps/sim/stores/folders/types.ts +++ b/apps/sim/stores/folders/types.ts @@ -1,4 +1,4 @@ -export type FolderResourceType = 'workflow' | 'file' | 'knowledge_base' | 'table' +import type { FolderResourceType } from '@/lib/api/contracts/folders' export interface Folder { id: string diff --git a/packages/db/schema.ts b/packages/db/schema.ts index 5072244e6ae..537eae2cbfd 100644 --- a/packages/db/schema.ts +++ b/packages/db/schema.ts @@ -145,11 +145,11 @@ export const folder = pgTable( parentId: text('parent_id').references((): AnyPgColumn => folder.id, { onDelete: 'set null', }), - // locked is meaningful for workflow folders only today (lock-cascade feature); kept - // as a reserved-but-unused column for file/knowledge_base/table folders rather than - // splitting into per-resource tables. `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 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(), diff --git a/packages/platform-authz/src/resource-lock.ts b/packages/platform-authz/src/resource-lock.ts index 5ea6af10620..eacb1d0adea 100644 --- a/packages/platform-authz/src/resource-lock.ts +++ b/packages/platform-authz/src/resource-lock.ts @@ -262,27 +262,38 @@ export async function assertResourceMutable( } /** - * Like {@link assertFolderMutable}, but when `unlocking` is true, a DIRECT lock on - * `folderId` itself is treated as satisfied -- 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. An INHERITED lock (from a still-locked - * ancestor) still blocks regardless, since clearing this folder's own flag has no - * effect on its ancestors. + * 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. */ -export async function assertFolderMutableUnlessUnlocking( - folderId: string | null, - resourceType: FolderResourceType, - unlocking: boolean, - dbClient: DbOrTx = db +async function assertMutableUnlessUnlocking( + assert: () => Promise, + unlocking: boolean ): Promise { try { - await assertFolderMutable(folderId, resourceType, dbClient) + 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, @@ -290,10 +301,8 @@ export async function assertResourceMutableUnlessUnlocking( unlocking: boolean, dbClient: DbOrTx = db ): Promise { - try { - await assertResourceMutable(resourceType, resourceId, dbClient) - } catch (error) { - if (unlocking && error instanceof ResourceLockedError && !error.inherited) return - throw error - } + return assertMutableUnlessUnlocking( + () => assertResourceMutable(resourceType, resourceId, dbClient), + unlocking + ) } diff --git a/packages/platform-authz/src/workflow.ts b/packages/platform-authz/src/workflow.ts index 68ff4b57893..484faea65af 100644 --- a/packages/platform-authz/src/workflow.ts +++ b/packages/platform-authz/src/workflow.ts @@ -1,6 +1,7 @@ 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' @@ -94,9 +95,13 @@ export interface LockStatus { lockedFolderId: string | null } -function toWorkflowLockStatus( - status: Awaited> -): LockStatus { +/** + * `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 { ...status, lockedBy: status.lockedBy === 'resource' ? 'workflow' : status.lockedBy,