From 79a0636fabc900baf915512a67e1490a2734398a Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 28 Jul 2026 19:29:21 -0700 Subject: [PATCH 01/11] feat(folders): cut file folders over, and give knowledge bases and tables folders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds on the generic resourceType-driven folder engine to finish the migration and extend it to two more resource trees. File-folder cutover - Repoints all 30 `workspace_file_folders` query sites onto `folder` scoped to `resourceType = 'file'`, id-keyed lookups included — a caller can hand a workflow folder's id to a file-folder endpoint, and only the predicate stops it - Adds the missing restore-conflict handling: a file folder whose name was taken while it was archived now deduplicates against the RESOLVED parent (restore re-roots when the original parent is still archived) instead of being permanently unrestorable - The forking mapper's `resourceType` predicate lives in the `leftJoin` condition, not the WHERE, where it would silently make the outer join inner - `servedFolderResourceTypeSchema` gains `file`; the soft-delete sweep enumerates all four types rather than dropping its predicate Knowledge Base and Tables folders - `knowledge_base.folder_id` and `user_table_definitions.folder_id` are now written and served; both create/move paths admit a folder only through `findActiveFolder`, scoped to workspace AND resourceType - Restoring a knowledge base whose folder is still archived re-roots it rather than filing it somewhere the page never renders; a workspace move re-roots for the same reason - Both pages get folder rows, breadcrumbs, inline rename on rows and on the breadcrumb, move-to submenus, pin/unpin, and drag-a-row-onto-a-folder Shared folder UI - One `components/folders/` module now backs Knowledge, Tables, and Files: `folderBreadcrumbItems` (data for `Resource.Header`, which owns the crumb chrome), `folderRow`, `folderRowId`/`parseFolderedRowId`, `buildMoveOptions` / `buildDescendantIndex` / `renderMoveOptions`, `nextUntitledFolderName`, `FolderContextMenu`, `useFolderNavigation`, `useFolderRowDragDrop` - Deletes the per-page copies: `files/move-options.tsx` and the Tables-local folder context menu Recently Deleted - Restores folders of every type, not just workflow folders, driven by one declarative table rather than a branch per resource Folder pins resolve against `folder` unscoped: file, knowledge-base, and table folders are all pinnable and all live there, and a folder id addresses exactly one row. --- apps/sim/app/api/knowledge/[id]/route.ts | 5 + apps/sim/app/api/knowledge/route.ts | 4 + .../sim/app/api/table/[tableId]/route.test.ts | 145 +++++ apps/sim/app/api/table/[tableId]/route.ts | 15 + .../app/api/table/import-async/route.test.ts | 33 + apps/sim/app/api/table/import-async/route.ts | 10 +- apps/sim/app/api/table/import-csv/route.ts | 19 + apps/sim/app/api/table/route.test.ts | 154 +++++ apps/sim/app/api/table/route.ts | 13 + .../components/folders/folder-breadcrumbs.ts | 60 ++ .../folders/folder-context-menu.tsx | 117 ++++ .../components/folders/folder-naming.ts | 26 + .../components/folders/folder-row-id.ts | 28 + .../components/folders/folder-row.tsx | 45 ++ .../components/folders/folders.test.ts | 241 ++++++++ .../[workspaceId]/components/folders/index.ts | 22 + .../components/folders/move-options.tsx | 161 +++++ .../components/folders/search-params.ts | 23 + .../folders/use-folder-navigation.ts | 102 +++ .../folders/use-folder-row-drag-drop.ts | 169 +++++ .../components/action-bar/action-bar.tsx | 4 +- .../file-row-context-menu.tsx | 4 +- .../workspace/[workspaceId]/files/files.tsx | 2 +- .../[workspaceId]/files/move-options.tsx | 42 -- .../create-base-modal/create-base-modal.tsx | 7 + .../knowledge-base-context-menu.tsx | 35 +- .../knowledge-list-context-menu.tsx | 14 +- .../[workspaceId]/knowledge/knowledge.tsx | 570 +++++++++++++++-- .../recently-deleted/recently-deleted.tsx | 73 ++- .../table-context-menu/table-context-menu.tsx | 25 +- .../tables-list-context-menu.tsx | 12 +- .../[workspaceId]/tables/loading.tsx | 3 +- .../[workspaceId]/tables/prefetch.ts | 46 +- .../[workspaceId]/tables/search-params.ts | 6 + .../workspace/[workspaceId]/tables/tables.tsx | 580 ++++++++++++++++-- apps/sim/background/cleanup-soft-deletes.ts | 16 +- .../lib/mapping/resources.ts | 11 +- apps/sim/hooks/queries/folders.ts | 38 +- apps/sim/hooks/queries/kb/knowledge.ts | 28 +- apps/sim/hooks/queries/tables.ts | 54 +- apps/sim/lib/api/contracts/folders.ts | 10 +- apps/sim/lib/api/contracts/knowledge/base.ts | 11 + apps/sim/lib/api/contracts/pinned-items.ts | 5 + apps/sim/lib/api/contracts/primitives.ts | 11 + apps/sim/lib/api/contracts/tables.ts | 30 +- .../tools/handlers/materialize-file.ts | 11 +- apps/sim/lib/folders/cascade.test.ts | 46 ++ apps/sim/lib/folders/lifecycle.ts | 11 +- apps/sim/lib/folders/queries.ts | 34 + apps/sim/lib/knowledge/folders.test.ts | 202 ++++++ apps/sim/lib/knowledge/service.ts | 113 +++- apps/sim/lib/knowledge/types.ts | 5 + apps/sim/lib/pinned-items/resources.ts | 23 +- apps/sim/lib/table/service.ts | 62 ++ apps/sim/lib/table/types.ts | 4 + .../workspace-file-folder-manager.ts | 330 +++++----- 56 files changed, 3490 insertions(+), 380 deletions(-) create mode 100644 apps/sim/app/api/table/[tableId]/route.test.ts create mode 100644 apps/sim/app/api/table/route.test.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/components/folders/folder-breadcrumbs.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/components/folders/folder-context-menu.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/components/folders/folder-naming.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/components/folders/folder-row-id.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/components/folders/folder-row.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/components/folders/folders.test.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/components/folders/index.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/components/folders/move-options.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/components/folders/search-params.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/components/folders/use-folder-navigation.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/components/folders/use-folder-row-drag-drop.ts delete mode 100644 apps/sim/app/workspace/[workspaceId]/files/move-options.tsx create mode 100644 apps/sim/lib/knowledge/folders.test.ts diff --git a/apps/sim/app/api/knowledge/[id]/route.ts b/apps/sim/app/api/knowledge/[id]/route.ts index 34a85d1a684..cd47c173ab4 100644 --- a/apps/sim/app/api/knowledge/[id]/route.ts +++ b/apps/sim/app/api/knowledge/[id]/route.ts @@ -11,6 +11,7 @@ import { deleteKnowledgeBase, getKnowledgeBaseById, KnowledgeBaseConflictError, + KnowledgeBaseFolderError, KnowledgeBasePermissionError, updateKnowledgeBase, } from '@/lib/knowledge/service' @@ -100,6 +101,7 @@ export const PUT = withRouteHandler( name: validatedData.name, description: validatedData.description, workspaceId: validatedData.workspaceId, + folderId: validatedData.folderId, chunkingConfig: validatedData.chunkingConfig, }, requestId, @@ -143,6 +145,9 @@ export const PUT = withRouteHandler( if (error instanceof KnowledgeBaseConflictError) { return NextResponse.json({ error: error.message }, { status: 409 }) } + if (error instanceof KnowledgeBaseFolderError) { + return NextResponse.json({ error: error.message }, { status: 400 }) + } if (error instanceof KnowledgeBasePermissionError) { logger.warn(`[${requestId}] Forbidden knowledge base update on ${id}: ${error.message}`) return NextResponse.json({ error: error.message }, { status: 403 }) diff --git a/apps/sim/app/api/knowledge/route.ts b/apps/sim/app/api/knowledge/route.ts index e14efd14656..b2f9177b49d 100644 --- a/apps/sim/app/api/knowledge/route.ts +++ b/apps/sim/app/api/knowledge/route.ts @@ -15,6 +15,7 @@ import { createKnowledgeBase, getKnowledgeBases, KnowledgeBaseConflictError, + KnowledgeBaseFolderError, KnowledgeBasePermissionError, type KnowledgeBaseScope, } from '@/lib/knowledge/service' @@ -160,6 +161,9 @@ export const POST = withRouteHandler(async (req: NextRequest) => { if (createError instanceof KnowledgeBaseConflictError) { return NextResponse.json({ error: createError.message }, { status: 409 }) } + if (createError instanceof KnowledgeBaseFolderError) { + return NextResponse.json({ error: createError.message }, { status: 400 }) + } if (createError instanceof KnowledgeBasePermissionError) { logger.warn(`[${requestId}] Forbidden knowledge base creation: ${createError.message}`) return NextResponse.json({ error: createError.message }, { status: 403 }) diff --git a/apps/sim/app/api/table/[tableId]/route.test.ts b/apps/sim/app/api/table/[tableId]/route.test.ts new file mode 100644 index 00000000000..633d52ebd10 --- /dev/null +++ b/apps/sim/app/api/table/[tableId]/route.test.ts @@ -0,0 +1,145 @@ +/** + * @vitest-environment node + */ +import { hybridAuthMockFns } from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockCheckAccess, + mockDeleteTable, + mockGetTableById, + mockMoveTableToFolder, + mockRenameTable, + mockUpdateTableLocks, + mockFindActiveFolder, + mockGetLimits, +} = vi.hoisted(() => ({ + mockCheckAccess: vi.fn(), + mockDeleteTable: vi.fn(), + mockGetTableById: vi.fn(), + mockMoveTableToFolder: vi.fn(), + mockRenameTable: vi.fn(), + mockUpdateTableLocks: vi.fn(), + mockFindActiveFolder: vi.fn(), + mockGetLimits: vi.fn(), +})) + +vi.mock('@/lib/table', () => ({ + deleteTable: mockDeleteTable, + getTableById: mockGetTableById, + moveTableToFolder: mockMoveTableToFolder, + renameTable: mockRenameTable, + updateTableLocks: mockUpdateTableLocks, + TableConflictError: class extends Error {}, +})) +vi.mock('@/lib/table/billing', () => ({ getWorkspaceTableLimits: mockGetLimits })) +vi.mock('@/lib/folders/queries', () => ({ findActiveFolder: mockFindActiveFolder })) +vi.mock('@/lib/core/config/feature-flags', () => ({ isFeatureEnabled: vi.fn() })) +vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: vi.fn() })) +vi.mock('@/lib/workspaces/permissions/utils', () => ({ + getWorkspaceWithOwner: vi.fn(), + getUserEntityPermissions: vi.fn(), +})) +vi.mock('@/app/api/table/utils', () => ({ + accessError: () => new Response('denied', { status: 403 }), + checkAccess: mockCheckAccess, + normalizeColumn: (column: unknown) => column, + tableLockErrorResponse: () => null, +})) + +import { PATCH } from '@/app/api/table/[tableId]/route' + +const TABLE = { + id: 'tbl_1', + name: 'people', + workspaceId: 'workspace-1', + folderId: null as string | null, + schema: { columns: [] }, + locks: { + schemaLocked: false, + insertLocked: false, + updateLocked: false, + deleteLocked: false, + }, +} + +function patchRequest(body: unknown): NextRequest { + return new NextRequest('http://localhost:3000/api/table/tbl_1', { + method: 'PATCH', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }) +} + +const routeContext = { params: Promise.resolve({ tableId: 'tbl_1' }) } + +describe('PATCH /api/table/[tableId] folder moves', () => { + beforeEach(() => { + vi.clearAllMocks() + hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({ + success: true, + userId: 'user-1', + authType: 'session', + }) + mockCheckAccess.mockResolvedValue({ ok: true, table: TABLE }) + mockGetTableById.mockResolvedValue({ ...TABLE, folderId: 'folder-1' }) + mockFindActiveFolder.mockResolvedValue({ id: 'folder-1' }) + }) + + it('moves the table into a folder in the same workspace and tree', async () => { + const response = await PATCH( + patchRequest({ workspaceId: 'workspace-1', folderId: 'folder-1' }), + routeContext + ) + + expect(response.status).toBe(200) + expect(mockFindActiveFolder).toHaveBeenCalledWith('folder-1', 'workspace-1', 'table') + expect(mockMoveTableToFolder).toHaveBeenCalledWith( + 'tbl_1', + 'folder-1', + expect.any(String), + 'user-1' + ) + }) + + it('moves the table to the workspace root on an explicit null, with no folder lookup', async () => { + mockGetTableById.mockResolvedValue({ ...TABLE, folderId: null }) + + const response = await PATCH( + patchRequest({ workspaceId: 'workspace-1', folderId: null }), + routeContext + ) + + expect(response.status).toBe(200) + expect(mockFindActiveFolder).not.toHaveBeenCalled() + expect(mockMoveTableToFolder).toHaveBeenCalledWith('tbl_1', null, expect.any(String), 'user-1') + }) + + it('leaves placement untouched when folderId is omitted', async () => { + await PATCH(patchRequest({ workspaceId: 'workspace-1', name: 'renamed' }), routeContext) + + expect(mockRenameTable).toHaveBeenCalled() + expect(mockMoveTableToFolder).not.toHaveBeenCalled() + }) + + it('rejects a folder from another workspace or resource tree without writing', async () => { + mockFindActiveFolder.mockResolvedValue(null) + + const response = await PATCH( + patchRequest({ workspaceId: 'workspace-1', folderId: 'kb-folder' }), + routeContext + ) + + expect(response.status).toBe(404) + expect(mockMoveTableToFolder).not.toHaveBeenCalled() + }) + + it('rejects a body with no name, folder, or lock changes', async () => { + const response = await PATCH(patchRequest({ workspaceId: 'workspace-1' }), routeContext) + + expect(response.status).toBe(400) + expect(mockMoveTableToFolder).not.toHaveBeenCalled() + expect(mockRenameTable).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/table/[tableId]/route.ts b/apps/sim/app/api/table/[tableId]/route.ts index ec78330932a..d1f084fd1d8 100644 --- a/apps/sim/app/api/table/[tableId]/route.ts +++ b/apps/sim/app/api/table/[tableId]/route.ts @@ -7,10 +7,12 @@ import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { isFeatureEnabled } from '@/lib/core/config/feature-flags' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { findActiveFolder } from '@/lib/folders/queries' import { captureServerEvent } from '@/lib/posthog/server' import { deleteTable, getTableById, + moveTableToFolder, renameTable, TableConflictError, type TableSchema, @@ -80,6 +82,7 @@ export const GET = withRouteHandler(async (request: NextRequest, { params }: Tab metadata: table.metadata ?? null, rowCount: table.rowCount, maxRows: maxRowsPerTable, + folderId: table.folderId ?? null, locks: table.locks, createdAt: table.createdAt instanceof Date @@ -184,6 +187,18 @@ export const PATCH = withRouteHandler( await renameTable(tableId, validated.name, requestId, authResult.userId) } + if (validated.folderId !== undefined) { + // Scoped to `resourceType: 'table'` so a folder id from another resource's + // tree can't be used to file the table somewhere Tables never lists. + if ( + validated.folderId !== null && + !(await findActiveFolder(validated.folderId, table.workspaceId, 'table')) + ) { + return NextResponse.json({ error: 'Folder not found in this workspace' }, { status: 404 }) + } + await moveTableToFolder(tableId, validated.folderId, requestId, authResult.userId) + } + // Re-read so the response reflects both a rename and a lock change. const updated = await getTableById(tableId) if (!updated) { diff --git a/apps/sim/app/api/table/import-async/route.test.ts b/apps/sim/app/api/table/import-async/route.test.ts index eaaf90597cc..4bb0b65bac2 100644 --- a/apps/sim/app/api/table/import-async/route.test.ts +++ b/apps/sim/app/api/table/import-async/route.test.ts @@ -11,6 +11,7 @@ const { mockListTables, mockRunTableImport, mockRunDetached, + mockFindActiveFolder, MockTableConflictError, } = vi.hoisted(() => ({ mockCreateTable: vi.fn(), @@ -18,6 +19,7 @@ const { mockListTables: vi.fn(), mockRunTableImport: vi.fn(), mockRunDetached: vi.fn(), + mockFindActiveFolder: vi.fn(), MockTableConflictError: class extends Error { readonly code = 'TABLE_EXISTS' as const }, @@ -37,6 +39,7 @@ vi.mock('@/lib/table', () => ({ TableConflictError: MockTableConflictError, })) vi.mock('@/lib/table/import-runner', () => ({ runTableImport: mockRunTableImport })) +vi.mock('@/lib/folders/queries', () => ({ findActiveFolder: mockFindActiveFolder })) vi.mock('@/lib/core/utils/background', () => ({ runDetached: mockRunDetached.mockImplementation( (_label: string, work: () => Promise) => { @@ -75,6 +78,36 @@ describe('POST /api/table/import-async', () => { mockListTables.mockResolvedValue([]) mockCreateTable.mockResolvedValue({ id: 'tbl_async', name: 'data' }) mockRunTableImport.mockResolvedValue(undefined) + mockFindActiveFolder.mockResolvedValue({ id: 'folder-1' }) + }) + + it('imports into the workspace root when no folder is given', async () => { + await POST(makeRequest(validBody)) + + expect(mockFindActiveFolder).not.toHaveBeenCalled() + expect(mockCreateTable).toHaveBeenCalledWith( + expect.objectContaining({ folderId: undefined }), + expect.any(String) + ) + }) + + it('creates the imported table inside the requested folder', async () => { + await POST(makeRequest({ ...validBody, folderId: 'folder-1' })) + + expect(mockFindActiveFolder).toHaveBeenCalledWith('folder-1', 'workspace-1', 'table') + expect(mockCreateTable).toHaveBeenCalledWith( + expect.objectContaining({ folderId: 'folder-1' }), + expect.any(String) + ) + }) + + it('rejects a folder from another workspace or resource tree', async () => { + mockFindActiveFolder.mockResolvedValue(null) + + const response = await POST(makeRequest({ ...validBody, folderId: 'kb-folder' })) + + expect(response.status).toBe(404) + expect(mockCreateTable).not.toHaveBeenCalled() }) it('creates an importing table and kicks off the background import', async () => { diff --git a/apps/sim/app/api/table/import-async/route.ts b/apps/sim/app/api/table/import-async/route.ts index 500429075df..57d879c1f32 100644 --- a/apps/sim/app/api/table/import-async/route.ts +++ b/apps/sim/app/api/table/import-async/route.ts @@ -8,6 +8,7 @@ import { isTriggerDevEnabled } from '@/lib/core/config/env-flags' import { runDetached } from '@/lib/core/utils/background' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { findActiveFolder } from '@/lib/folders/queries' import { captureServerEvent } from '@/lib/posthog/server' import { createTable, @@ -39,7 +40,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const parsed = await parseRequest(importTableAsyncContract, request, {}) if (!parsed.success) return parsed.response - const { workspaceId, fileKey, fileName, deleteSourceFile, timezone } = parsed.data.body + const { workspaceId, fileKey, fileName, folderId, deleteSourceFile, timezone } = parsed.data.body const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId) if (permission !== 'write' && permission !== 'admin') { @@ -51,6 +52,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: 'Invalid file key for workspace' }, { status: 400 }) } + // Scoped to `resourceType: 'table'` so a folder from another resource's tree + // can't file the imported table where Tables never lists it. + if (folderId && !(await findActiveFolder(folderId, workspaceId, 'table'))) { + return NextResponse.json({ error: 'Folder not found in this workspace' }, { status: 404 }) + } + const ext = fileName.split('.').pop()?.toLowerCase() if (ext !== 'csv' && ext !== 'tsv') { return NextResponse.json({ error: 'Only CSV and TSV files are supported' }, { status: 400 }) @@ -84,6 +91,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { description: `Imported from ${fileName}`, schema: { columns: [{ name: 'column_1', type: 'string' }] }, workspaceId, + folderId, userId, maxTables: planLimits.maxTables, jobStatus: 'running', diff --git a/apps/sim/app/api/table/import-csv/route.ts b/apps/sim/app/api/table/import-csv/route.ts index 2c0a3cf6614..9ca0381fe90 100644 --- a/apps/sim/app/api/table/import-csv/route.ts +++ b/apps/sim/app/api/table/import-csv/route.ts @@ -10,6 +10,7 @@ import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { isMultipartError, readMultipart } from '@/lib/core/utils/multipart' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { findActiveFolder } from '@/lib/folders/queries' import { batchInsertRows, CSV_MAX_BATCH_SIZE, @@ -88,6 +89,23 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: 'Access denied' }, { status: 403 }) } + let folderId: string | null = null + if (fields.folderId) { + const folderIdResult = csvImportFormSchema.shape.folderId.safeParse(fields.folderId) + if (!folderIdResult.success) { + return NextResponse.json( + { error: getValidationErrorMessage(folderIdResult.error) }, + { status: 400 } + ) + } + // Scoped to `resourceType: 'table'` so a folder from another resource's tree + // can't file the imported table where Tables never lists it. + if (!(await findActiveFolder(folderIdResult.data as string, workspaceId, 'table'))) { + return NextResponse.json({ error: 'Folder not found in this workspace' }, { status: 404 }) + } + folderId = folderIdResult.data as string + } + let timezone = (await getUserSettings(userId)).timezone ?? 'UTC' if (fields.timezone) { const timezoneResult = ianaTimezoneSchema.safeParse(fields.timezone) @@ -161,6 +179,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { description: `Imported from ${file.filename}`, schema, workspaceId, + folderId, userId, maxTables: planLimits.maxTables, }, diff --git a/apps/sim/app/api/table/route.test.ts b/apps/sim/app/api/table/route.test.ts new file mode 100644 index 00000000000..0932258551d --- /dev/null +++ b/apps/sim/app/api/table/route.test.ts @@ -0,0 +1,154 @@ +/** + * @vitest-environment node + */ +import { hybridAuthMockFns, permissionsMock, permissionsMockFns } from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockCreateTable, mockGetLimits, mockListTables, mockFindActiveFolder } = vi.hoisted(() => ({ + mockCreateTable: vi.fn(), + mockGetLimits: vi.fn(), + mockListTables: vi.fn(), + mockFindActiveFolder: vi.fn(), +})) + +vi.mock('@/lib/table', () => ({ + createTable: mockCreateTable, + getWorkspaceTableLimits: mockGetLimits, + listTables: mockListTables, +})) +vi.mock('@/lib/folders/queries', () => ({ findActiveFolder: mockFindActiveFolder })) +vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock) +vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: vi.fn() })) +vi.mock('@sim/audit', () => ({ + AuditAction: { TABLE_CREATED: 'table.created' }, + AuditResourceType: { TABLE: 'table' }, + recordAudit: vi.fn(), +})) + +import { GET, POST } from '@/app/api/table/route' + +const CREATED_TABLE = { + id: 'tbl_1', + name: 'people', + description: null, + schema: { columns: [{ name: 'name', type: 'string' }] }, + rowCount: 0, + maxRows: 10_000, + folderId: null as string | null, + locks: { + schemaLocked: false, + insertLocked: false, + updateLocked: false, + deleteLocked: false, + }, + createdAt: new Date('2026-01-01T00:00:00.000Z'), + updatedAt: new Date('2026-01-01T00:00:00.000Z'), +} + +function postRequest(body: unknown): NextRequest { + return new NextRequest('http://localhost:3000/api/table', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }) +} + +const createBody = { + workspaceId: 'workspace-1', + name: 'people', + schema: { columns: [{ name: 'name', type: 'string' }] }, +} + +describe('POST /api/table folder assignment', () => { + beforeEach(() => { + vi.clearAllMocks() + hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({ + success: true, + userId: 'user-1', + authType: 'session', + }) + permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('write') + mockGetLimits.mockResolvedValue({ maxRowsPerTable: 1_000_000, maxTables: 50 }) + mockCreateTable.mockResolvedValue(CREATED_TABLE) + mockFindActiveFolder.mockResolvedValue({ id: 'folder-1' }) + }) + + it('creates the table at the workspace root when no folder is given', async () => { + const response = await POST(postRequest(createBody)) + + expect(response.status).toBe(200) + expect(mockFindActiveFolder).not.toHaveBeenCalled() + expect(mockCreateTable).toHaveBeenCalledWith( + expect.objectContaining({ folderId: null }), + expect.any(String) + ) + }) + + it('creates the table inside the requested folder', async () => { + mockCreateTable.mockResolvedValue({ ...CREATED_TABLE, folderId: 'folder-1' }) + + const response = await POST(postRequest({ ...createBody, folderId: 'folder-1' })) + const json = await response.json() + + expect(response.status).toBe(200) + expect(mockCreateTable).toHaveBeenCalledWith( + expect.objectContaining({ folderId: 'folder-1' }), + expect.any(String) + ) + expect(json.data.table.folderId).toBe('folder-1') + }) + + it('scopes the folder lookup to the workspace and the table folder tree', async () => { + await POST(postRequest({ ...createBody, folderId: 'folder-1' })) + + expect(mockFindActiveFolder).toHaveBeenCalledWith('folder-1', 'workspace-1', 'table') + }) + + it('rejects a folder id that does not resolve in this workspace or tree', async () => { + mockFindActiveFolder.mockResolvedValue(null) + + const response = await POST(postRequest({ ...createBody, folderId: 'kb-folder' })) + + expect(response.status).toBe(404) + expect(mockCreateTable).not.toHaveBeenCalled() + }) + + it('rejects an empty folder id before touching the database', async () => { + const response = await POST(postRequest({ ...createBody, folderId: '' })) + + expect(response.status).toBe(400) + expect(mockFindActiveFolder).not.toHaveBeenCalled() + expect(mockCreateTable).not.toHaveBeenCalled() + }) +}) + +describe('GET /api/table folder placement', () => { + beforeEach(() => { + vi.clearAllMocks() + hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({ + success: true, + userId: 'user-1', + authType: 'session', + }) + permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('read') + }) + + it('emits each table folderId so the list can group rows by folder', async () => { + mockListTables.mockResolvedValue([ + { ...CREATED_TABLE, id: 'tbl_1', folderId: 'folder-1', workspaceId: 'ws', createdBy: 'u' }, + { ...CREATED_TABLE, id: 'tbl_2', folderId: null, workspaceId: 'ws', createdBy: 'u' }, + ]) + + const response = await GET( + new NextRequest('http://localhost:3000/api/table?workspaceId=workspace-1') + ) + const json = await response.json() + + expect(response.status).toBe(200) + expect(json.data.tables.map((t: { folderId: string | null }) => t.folderId)).toEqual([ + 'folder-1', + null, + ]) + }) +}) diff --git a/apps/sim/app/api/table/route.ts b/apps/sim/app/api/table/route.ts index 7506a3f521d..2522cddb7c6 100644 --- a/apps/sim/app/api/table/route.ts +++ b/apps/sim/app/api/table/route.ts @@ -6,6 +6,7 @@ import { isZodError, parseRequest, validationErrorResponse } from '@/lib/api/ser import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { findActiveFolder } from '@/lib/folders/queries' import { captureServerEvent } from '@/lib/posthog/server' import { createTable, @@ -69,6 +70,15 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: 'Access denied' }, { status: 403 }) } + // Scoped to `resourceType: 'table'` so a folder id belonging to another + // resource's tree can't file a table where its own surface will never list it. + if ( + params.folderId && + !(await findActiveFolder(params.folderId, params.workspaceId, 'table')) + ) { + return NextResponse.json({ error: 'Folder not found in this workspace' }, { status: 404 }) + } + const planLimits = await getWorkspaceTableLimits(params.workspaceId) const normalizedSchema: TableSchema = { @@ -81,6 +91,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { description: params.description, schema: normalizedSchema, workspaceId: params.workspaceId, + folderId: params.folderId ?? null, userId: authResult.userId, maxTables: planLimits.maxTables, initialRowCount: params.initialRowCount, @@ -127,6 +138,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { }, rowCount: table.rowCount, maxRows: table.maxRows, + folderId: table.folderId ?? null, locks: table.locks, createdAt: table.createdAt instanceof Date @@ -209,6 +221,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { maxRows: t.maxRows, locks: t.locks, workspaceId: t.workspaceId, + folderId: t.folderId ?? null, 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/workspace/[workspaceId]/components/folders/folder-breadcrumbs.ts b/apps/sim/app/workspace/[workspaceId]/components/folders/folder-breadcrumbs.ts new file mode 100644 index 00000000000..4efa3fd8fbc --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/folders/folder-breadcrumbs.ts @@ -0,0 +1,60 @@ +import type { + BreadcrumbEditing, + BreadcrumbItem, + DropdownOption, +} from '@/app/workspace/[workspaceId]/components/resource/components/resource-header' +import type { WorkflowFolder } from '@/stores/folders/types' + +export interface FolderBreadcrumbItemsOptions { + /** Root crumb label — the page's own name ("Knowledge Base", "Tables"). */ + rootLabel: string + rootIcon?: React.ElementType + /** Root-first ancestor chain of the open folder, from `useFolderNavigation`. */ + breadcrumbs: WorkflowFolder[] + /** Called with the folder to open, or `null` for the workspace root. */ + onNavigate: (folderId: string | null) => void + /** Menu attached to the open folder's crumb (rename, delete, …). */ + currentFolderActions?: DropdownOption[] + /** Inline rename bound to the open folder's crumb. */ + currentFolderEditing?: BreadcrumbEditing +} + +/** + * Converts a folder ancestor chain into the `BreadcrumbItem[]` that `Resource.Header` + * renders. + * + * A plain builder rather than a component: `Resource.Header` already owns every piece of + * breadcrumb chrome — the root-crumb "Path" popover, segment width allocation, overflow + * tooltips, and the rule that a single-element trail renders as a plain page title. A + * sibling crumb component would have to fork all of it, which is exactly what this shared + * directory exists to prevent. + * + * The trail always starts with the root crumb, so at the workspace root the result has + * length 1 and the header renders the page title unchanged. + */ +export function folderBreadcrumbItems({ + rootLabel, + rootIcon, + breadcrumbs, + onNavigate, + currentFolderActions, + currentFolderEditing, +}: FolderBreadcrumbItemsOptions): BreadcrumbItem[] { + const items: BreadcrumbItem[] = [ + { label: rootLabel, icon: rootIcon, onClick: () => onNavigate(null) }, + ] + + breadcrumbs.forEach((folder, index) => { + const isCurrent = index === breadcrumbs.length - 1 + items.push({ + label: folder.name, + // The current folder is where you already are, so its crumb is not a navigation + // target — it carries the folder's own actions instead. + onClick: isCurrent ? undefined : () => onNavigate(folder.id), + dropdownItems: isCurrent && currentFolderActions?.length ? currentFolderActions : undefined, + editing: isCurrent ? currentFolderEditing : undefined, + }) + }) + + return items +} diff --git a/apps/sim/app/workspace/[workspaceId]/components/folders/folder-context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/components/folders/folder-context-menu.tsx new file mode 100644 index 00000000000..0f4785f915b --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/folders/folder-context-menu.tsx @@ -0,0 +1,117 @@ +'use client' + +import { memo } from 'react' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +} from '@sim/emcn' +import { Duplicate, Eye, FolderInput, Pencil, Pin, Trash } from '@sim/emcn/icons' +import type { MoveOptionNode } from '@/app/workspace/[workspaceId]/components/folders/move-options' +import { renderMoveOptions } from '@/app/workspace/[workspaceId]/components/folders/move-options' + +interface FolderContextMenuProps { + isOpen: boolean + position: { x: number; y: number } + onClose: () => void + onOpen: () => void + onRename: () => void + onDelete: () => void + onCopyId?: () => void + onMove?: (optionValue: string) => void + onTogglePin: () => void + /** Pin state of the right-clicked folder, driving the Pin/Unpin label. */ + pinned: boolean + moveOptions?: MoveOptionNode[] + canEdit: boolean +} + +/** + * Row context menu for a folder, shared across every foldered resource list so a folder + * offers the same actions on Knowledge, Tables, and Files. + * + * Mirrors the resource-row menus (`KnowledgeBaseContextMenu`, `FileRowContextMenu`): a + * `DropdownMenu` anchored to a one-pixel fixed trigger at the cursor, non-modal so the list + * behind it stays interactive. Folder locking is deliberately absent — it is a workflow-only + * feature and is not extended to the other resource trees. + */ +export const FolderContextMenu = memo(function FolderContextMenu({ + isOpen, + position, + onClose, + onOpen, + onRename, + onDelete, + onCopyId, + onMove, + onTogglePin, + pinned, + moveOptions, + canEdit, +}: FolderContextMenuProps) { + const hasMove = Boolean(onMove && moveOptions && moveOptions.length > 0) + + return ( + !open && onClose()} modal={false}> + +
+ + e.preventDefault()} + > + + + Open + + + + {pinned ? 'Unpin' : 'Pin'} + + {onCopyId && ( + + + Copy ID + + )} + {canEdit && ( + <> + + + + Rename + + {hasMove && ( + + + + Move to + + + {renderMoveOptions(moveOptions!, onMove!)} + + + )} + + + + Delete + + + )} + + + ) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/components/folders/folder-naming.ts b/apps/sim/app/workspace/[workspaceId]/components/folders/folder-naming.ts new file mode 100644 index 00000000000..6f4eeaf68ff --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/folders/folder-naming.ts @@ -0,0 +1,26 @@ +import type { WorkflowFolder } from '@/stores/folders/types' + +const DEFAULT_FOLDER_NAME = 'New folder' + +/** + * First `"New folder"` / `"New folder (N)"` name not already taken by an active sibling + * under `parentId`. + * + * The `folder` table has a partial unique index on active + * `(workspaceId, resourceType, parentId, name)`, and a create the user did not name has no + * way to recover from a 409 — so a "New folder" button dedups up front rather than surfacing + * a conflict. Deliberately matches the `" (N)"` shape of `deduplicateFolderName` on the + * server so a deduped name reads the same however it was produced. A concurrent create can + * still win the race; the caller must handle the 409 that follows. + */ +export function nextUntitledFolderName(folders: WorkflowFolder[], parentId: string | null): string { + const siblingNames = new Set( + folders.filter((folder) => (folder.parentId ?? null) === parentId).map((folder) => folder.name) + ) + + if (!siblingNames.has(DEFAULT_FOLDER_NAME)) return DEFAULT_FOLDER_NAME + + let suffix = 1 + while (siblingNames.has(`${DEFAULT_FOLDER_NAME} (${suffix})`)) suffix += 1 + return `${DEFAULT_FOLDER_NAME} (${suffix})` +} diff --git a/apps/sim/app/workspace/[workspaceId]/components/folders/folder-row-id.ts b/apps/sim/app/workspace/[workspaceId]/components/folders/folder-row-id.ts new file mode 100644 index 00000000000..f4561a3adef --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/folders/folder-row-id.ts @@ -0,0 +1,28 @@ +/** + * A `Resource.Table` row is addressed by a single id, but a foldered list interleaves two + * kinds of row. Namespacing the ids lets one selection `Set`, one context-menu handler, and + * one drop-target predicate cover both without a parallel lookup. + */ +const FOLDER_ROW_PREFIX = 'folder:' + +export type FolderedRowKind = 'folder' | 'resource' + +export interface ParsedFolderedRowId { + kind: FolderedRowKind + id: string +} + +export function folderRowId(folderId: string): string { + return `${FOLDER_ROW_PREFIX}${folderId}` +} + +/** + * Resource rows keep their bare id so existing handlers, deep links, and cached selections + * that predate folders keep resolving; only folder rows carry a prefix. + */ +export function parseFolderedRowId(rowId: string): ParsedFolderedRowId { + if (rowId.startsWith(FOLDER_ROW_PREFIX)) { + return { kind: 'folder', id: rowId.slice(FOLDER_ROW_PREFIX.length) } + } + return { kind: 'resource', id: rowId } +} diff --git a/apps/sim/app/workspace/[workspaceId]/components/folders/folder-row.tsx b/apps/sim/app/workspace/[workspaceId]/components/folders/folder-row.tsx new file mode 100644 index 00000000000..25e0ee3e65c --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/folders/folder-row.tsx @@ -0,0 +1,45 @@ +import { Folder } from '@sim/emcn/icons' +import { folderRowId } from '@/app/workspace/[workspaceId]/components/folders/folder-row-id' +import type { + ResourceCell, + ResourceRow, +} from '@/app/workspace/[workspaceId]/components/resource/resource' +import type { WorkflowFolder } from '@/stores/folders/types' + +const FOLDER_ICON = + +export interface FolderRowOptions { + /** + * Cells for the page's non-name columns, keyed by `ResourceColumn.id`. Folders have no + * value for most resource-specific columns, so a page passes either a derived roll-up or + * the em-dash placeholder. + */ + cells?: Record + /** Column id carrying the name cell. Defaults to `'name'`. */ + nameColumnId?: string +} + +/** + * Builds the canonical folder `ResourceRow` shared by every foldered list, so a folder looks + * and behaves identically on Knowledge, Tables, and Files. The row id is namespaced by + * {@link folderRowId}, so the caller's click/context-menu handlers distinguish a folder from + * a resource with `parseFolderedRowId` rather than a second lookup. + * + * Pinning is not a cell: it lives in the row's context menu (see `FolderContextMenu`), and + * inline rename is layered over the built rows by the page so a keystroke in the rename + * field rebuilds one cell instead of every row's cells. + */ +export function folderRow(folder: WorkflowFolder, options: FolderRowOptions = {}): ResourceRow { + const { cells, nameColumnId = 'name' } = options + + return { + id: folderRowId(folder.id), + cells: { + ...cells, + [nameColumnId]: { + icon: FOLDER_ICON, + label: folder.name, + }, + }, + } +} diff --git a/apps/sim/app/workspace/[workspaceId]/components/folders/folders.test.ts b/apps/sim/app/workspace/[workspaceId]/components/folders/folders.test.ts new file mode 100644 index 00000000000..9818053d6e4 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/folders/folders.test.ts @@ -0,0 +1,241 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it, vi } from 'vitest' +import { folderBreadcrumbItems } from '@/app/workspace/[workspaceId]/components/folders/folder-breadcrumbs' +import { nextUntitledFolderName } from '@/app/workspace/[workspaceId]/components/folders/folder-naming' +import { + folderRowId, + parseFolderedRowId, +} from '@/app/workspace/[workspaceId]/components/folders/folder-row-id' +import { + buildDescendantIndex, + buildMoveOptions, + parseMoveOptionValue, + ROOT_MOVE_OPTION_VALUE, +} from '@/app/workspace/[workspaceId]/components/folders/move-options' +import type { WorkflowFolder } from '@/stores/folders/types' + +function makeFolder( + id: string, + parentId: string | null = null, + overrides: Partial = {} +): WorkflowFolder { + return { + id, + name: id, + userId: 'u-1', + workspaceId: 'ws-1', + parentId, + resourceType: 'knowledge_base', + locked: false, + sortOrder: 0, + createdAt: new Date('2026-01-01T00:00:00.000Z'), + updatedAt: new Date('2026-01-01T00:00:00.000Z'), + deletedAt: null, + ...overrides, + } +} + +describe('folder row ids', () => { + it('round-trips a folder id through the namespaced row id', () => { + expect(parseFolderedRowId(folderRowId('f-1'))).toEqual({ kind: 'folder', id: 'f-1' }) + }) + + it('treats an unprefixed id as the resource, so pre-folder row ids still resolve', () => { + expect(parseFolderedRowId('kb-1')).toEqual({ kind: 'resource', id: 'kb-1' }) + }) + + it('does not mistake a resource id that merely contains the prefix for a folder', () => { + expect(parseFolderedRowId('kb-folder:1')).toEqual({ kind: 'resource', id: 'kb-folder:1' }) + }) + + it('keeps a folder id containing a colon intact', () => { + expect(parseFolderedRowId(folderRowId('a:b'))).toEqual({ kind: 'folder', id: 'a:b' }) + }) +}) + +describe('nextUntitledFolderName', () => { + it('uses the bare name when no sibling holds it', () => { + expect(nextUntitledFolderName([], null)).toBe('New folder') + }) + + it('suffixes past every taken sibling name', () => { + const folders = [ + makeFolder('a', null, { name: 'New folder' }), + makeFolder('b', null, { name: 'New folder (1)' }), + ] + expect(nextUntitledFolderName(folders, null)).toBe('New folder (2)') + }) + + it('only considers siblings under the same parent', () => { + const folders = [makeFolder('a', 'p-1', { name: 'New folder' })] + expect(nextUntitledFolderName(folders, null)).toBe('New folder') + expect(nextUntitledFolderName(folders, 'p-1')).toBe('New folder (1)') + }) +}) + +describe('buildDescendantIndex', () => { + it('collects transitive descendants', () => { + const index = buildDescendantIndex([ + makeFolder('root'), + makeFolder('child', 'root'), + makeFolder('grandchild', 'child'), + makeFolder('other'), + ]) + + expect([...(index.get('root') ?? [])].sort()).toEqual(['child', 'grandchild']) + expect([...(index.get('child') ?? [])]).toEqual(['grandchild']) + expect([...(index.get('other') ?? [])]).toEqual([]) + }) + + it('terminates on a cycle instead of recursing forever', () => { + const index = buildDescendantIndex([makeFolder('a', 'b'), makeFolder('b', 'a')]) + expect(index.has('a')).toBe(true) + expect(index.has('b')).toBe(true) + }) +}) + +describe('buildMoveOptions', () => { + const folders = [makeFolder('root'), makeFolder('child', 'root'), makeFolder('sibling')] + + it('leads with the root sentinel', () => { + const options = buildMoveOptions({ folders, rootLabel: 'Knowledge Base' }) + expect(options[0]).toEqual({ + value: ROOT_MOVE_OPTION_VALUE, + label: 'Knowledge Base', + children: [], + }) + }) + + it('nests descendants under their parent', () => { + const options = buildMoveOptions({ + folders: [...folders, makeFolder('grandchild', 'child')], + rootLabel: 'Knowledge Base', + }) + const root = options.find((option) => option.value === 'root') + expect(root?.children.map((child) => child.value)).toEqual(['child']) + expect(root?.children[0].children.map((child) => child.value)).toEqual(['grandchild']) + }) + + it('excludes the moved folder and its subtree, so a move cannot close a cycle', () => { + const excluded = new Set(['root', ...(buildDescendantIndex(folders).get('root') ?? [])]) + const options = buildMoveOptions({ + folders, + rootLabel: 'Knowledge Base', + excludedFolderIds: excluded, + }) + + expect(options.map((option) => option.value)).toEqual([ROOT_MOVE_OPTION_VALUE, 'sibling']) + }) + + it('still offers the root when every folder is excluded', () => { + const options = buildMoveOptions({ + folders, + rootLabel: 'Knowledge Base', + excludedFolderIds: new Set(['root', 'child', 'sibling']), + }) + expect(options).toHaveLength(1) + expect(options[0].value).toBe(ROOT_MOVE_OPTION_VALUE) + }) + + it('offers every folder when nothing is excluded, as for a resource with no subtree', () => { + const options = buildMoveOptions({ folders, rootLabel: 'Knowledge Base' }) + expect(options.map((option) => option.value)).toEqual([ + ROOT_MOVE_OPTION_VALUE, + 'root', + 'sibling', + ]) + }) + + it('orders siblings by sortOrder, then name', () => { + const options = buildMoveOptions({ + folders: [ + makeFolder('b', null, { name: 'B', sortOrder: 1 }), + makeFolder('a', null, { name: 'A', sortOrder: 2 }), + ], + rootLabel: 'Knowledge Base', + }) + expect(options.slice(1).map((option) => option.label)).toEqual(['B', 'A']) + }) + + it('does not mutate the caller folder array while sorting', () => { + const source = [...folders] + buildMoveOptions({ folders: source, rootLabel: 'Knowledge Base' }) + expect(source.map((folder) => folder.id)).toEqual(['root', 'child', 'sibling']) + }) +}) + +describe('parseMoveOptionValue', () => { + it('decodes the root sentinel to null', () => { + expect(parseMoveOptionValue(ROOT_MOVE_OPTION_VALUE)).toBeNull() + }) + + it('passes a folder id through unchanged', () => { + expect(parseMoveOptionValue('folder-1')).toBe('folder-1') + }) +}) + +describe('folderBreadcrumbItems', () => { + it('renders the root alone when no folder is open, so the header falls back to a title', () => { + const items = folderBreadcrumbItems({ + rootLabel: 'Knowledge Base', + breadcrumbs: [], + onNavigate: vi.fn(), + }) + expect(items).toHaveLength(1) + expect(items[0].label).toBe('Knowledge Base') + }) + + it('prepends the root crumb to the ancestor chain', () => { + const items = folderBreadcrumbItems({ + rootLabel: 'Tables', + breadcrumbs: [ + makeFolder('root', null, { name: 'Alpha' }), + makeFolder('leaf', 'root', { name: 'Beta' }), + ], + onNavigate: vi.fn(), + }) + expect(items.map((item) => item.label)).toEqual(['Tables', 'Alpha', 'Beta']) + }) + + it('navigates to null from the root crumb and to the folder id from an ancestor', () => { + const onNavigate = vi.fn() + const items = folderBreadcrumbItems({ + rootLabel: 'Tables', + breadcrumbs: [makeFolder('root'), makeFolder('leaf', 'root')], + onNavigate, + }) + + items[0].onClick?.() + items[1].onClick?.() + + expect(onNavigate).toHaveBeenNthCalledWith(1, null) + expect(onNavigate).toHaveBeenNthCalledWith(2, 'root') + }) + + it('attaches the rename session and actions to the current folder only', () => { + const currentFolderEditing = { + isEditing: true, + value: 'x', + onChange: vi.fn(), + onSubmit: vi.fn(), + onCancel: vi.fn(), + } + const currentFolderActions = [{ label: 'Rename', onClick: vi.fn() }] + const items = folderBreadcrumbItems({ + rootLabel: 'Knowledge Base', + breadcrumbs: [makeFolder('root'), makeFolder('leaf', 'root')], + onNavigate: vi.fn(), + currentFolderEditing, + currentFolderActions, + }) + + expect(items[1].onClick).toBeTypeOf('function') + expect(items[1].editing).toBeUndefined() + expect(items[1].dropdownItems).toBeUndefined() + expect(items[2].onClick).toBeUndefined() + expect(items[2].editing).toBe(currentFolderEditing) + expect(items[2].dropdownItems).toBe(currentFolderActions) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/components/folders/index.ts b/apps/sim/app/workspace/[workspaceId]/components/folders/index.ts new file mode 100644 index 00000000000..acc8e52c454 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/folders/index.ts @@ -0,0 +1,22 @@ +export type { FolderBreadcrumbItemsOptions } from './folder-breadcrumbs' +export { folderBreadcrumbItems } from './folder-breadcrumbs' +export { FolderContextMenu } from './folder-context-menu' +export { nextUntitledFolderName } from './folder-naming' +export type { FolderRowOptions } from './folder-row' +export { folderRow } from './folder-row' +export type { FolderedRowKind, ParsedFolderedRowId } from './folder-row-id' +export { folderRowId, parseFolderedRowId } from './folder-row-id' +export type { BuildMoveOptionsParams, MoveOptionNode } from './move-options' +export { + buildDescendantIndex, + buildMoveOptions, + parseMoveOptionValue, + ROOT_MOVE_OPTION_VALUE, + renderMoveOption, + renderMoveOptions, +} from './move-options' +export { folderNavParsers, folderNavUrlKeys } from './search-params' +export type { FolderNavigation, UseFolderNavigationOptions } from './use-folder-navigation' +export { useFolderNavigation } from './use-folder-navigation' +export type { UseFolderRowDragDropOptions } from './use-folder-row-drag-drop' +export { useFolderRowDragDrop } from './use-folder-row-drag-drop' diff --git a/apps/sim/app/workspace/[workspaceId]/components/folders/move-options.tsx b/apps/sim/app/workspace/[workspaceId]/components/folders/move-options.tsx new file mode 100644 index 00000000000..ec75e82034a --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/folders/move-options.tsx @@ -0,0 +1,161 @@ +import type { ReactNode } from 'react' +import { + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, +} from '@sim/emcn' +import { Folder } from '@sim/emcn/icons' +import type { WorkflowFolder } from '@/stores/folders/types' + +export interface MoveOptionNode { + value: string + label: string + children: MoveOptionNode[] +} + +/** + * Sentinel for "the workspace root" in a move submenu. A folder id can never be empty, so + * this cannot collide with a real destination. + */ +export const ROOT_MOVE_OPTION_VALUE = '__root__' + +/** Decodes a selected move-option value back into a `folderId` / `parentId`. */ +export function parseMoveOptionValue(optionValue: string): string | null { + return optionValue === ROOT_MOVE_OPTION_VALUE ? null : optionValue +} + +export interface BuildMoveOptionsParams { + folders: WorkflowFolder[] + rootLabel: string + /** + * Folder ids that must not appear as destinations — the folder being moved and every + * folder beneath it, which would otherwise close a cycle. Build the subtree half with + * {@link buildDescendantIndex}. + */ + excludedFolderIds?: ReadonlySet +} + +/** + * Builds the folder tree offered by a "Move to" submenu, always led by the root option. + * + * Children are indexed by parent once so the tree walk stays linear in the number of + * folders rather than re-filtering the whole list at every level. + */ +export function buildMoveOptions({ + folders, + rootLabel, + excludedFolderIds, +}: BuildMoveOptionsParams): MoveOptionNode[] { + const childrenByParent = new Map() + for (const folder of folders) { + if (excludedFolderIds?.has(folder.id)) continue + const parentId = folder.parentId ?? null + const siblings = childrenByParent.get(parentId) + if (siblings) siblings.push(folder) + else childrenByParent.set(parentId, [folder]) + } + + const buildSubtree = (parentId: string | null): MoveOptionNode[] => + [...(childrenByParent.get(parentId) ?? [])] + .sort((a, b) => a.sortOrder - b.sortOrder || a.name.localeCompare(b.name)) + .map((folder) => ({ + value: folder.id, + label: folder.name, + children: buildSubtree(folder.id), + })) + + return [{ value: ROOT_MOVE_OPTION_VALUE, label: rootLabel, children: [] }, ...buildSubtree(null)] +} + +/** + * Indexes every folder's transitive descendants once, so excluding a moved folder's subtree + * from {@link buildMoveOptions} (and rejecting a cyclic drop target) stays O(1) per + * candidate instead of re-walking the tree. `seen` terminates a cycle, which the DB permits + * between constraint checks. + */ +export function buildDescendantIndex(folders: WorkflowFolder[]): Map> { + const childrenByParent = new Map() + for (const folder of folders) { + if (!folder.parentId) continue + const children = childrenByParent.get(folder.parentId) + if (children) children.push(folder.id) + else childrenByParent.set(folder.parentId, [folder.id]) + } + + const result = new Map>() + const collect = (folderId: string, seen: Set): Set => { + const cached = result.get(folderId) + if (cached) return cached + if (seen.has(folderId)) return new Set() + + const nextSeen = new Set(seen) + nextSeen.add(folderId) + const descendants = new Set() + for (const childId of childrenByParent.get(folderId) ?? []) { + if (nextSeen.has(childId)) continue + descendants.add(childId) + for (const nestedId of collect(childId, nextSeen)) descendants.add(nestedId) + } + result.set(folderId, descendants) + return descendants + } + + for (const folder of folders) collect(folder.id, new Set()) + return result +} + +/** + * Renders one move target. A leaf is a plain item; a folder with children becomes a submenu + * whose own "Move here" entry leads its children, so an intermediate folder is still + * selectable while remaining traversable. + */ +export function renderMoveOption( + option: MoveOptionNode, + onMove: (value: string) => void +): ReactNode { + if (option.children.length === 0) { + return ( + onMove(option.value)}> + + {option.label} + + ) + } + + return ( + + + + {option.label} + + + onMove(option.value)}>Move here + + {option.children.map((child) => renderMoveOption(child, onMove))} + + + ) +} + +/** + * Renders a whole "Move to" submenu body: the root destination first, then the folder tree. + * Shared so every row menu that offers a move renders the same structure. + */ +export function renderMoveOptions( + options: MoveOptionNode[], + onMove: (value: string) => void +): ReactNode { + if (options.length === 0) return null + return ( + <> + onMove(options[0].value)}> + + {options[0].label} + + {options.length > 1 && } + {options.slice(1).map((option) => renderMoveOption(option, onMove))} + + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/components/folders/search-params.ts b/apps/sim/app/workspace/[workspaceId]/components/folders/search-params.ts new file mode 100644 index 00000000000..2ba2c0818f7 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/folders/search-params.ts @@ -0,0 +1,23 @@ +import { parseAsString } from 'nuqs/server' + +/** + * The open folder on any resource list built on the generic folder engine + * (`workflow` / `file` / `knowledge_base` / `table`). Declared once here rather than per + * feature so every foldered surface shares the same URL key and semantics, and so + * `useFolderNavigation` and a server `createSearchParamsCache` can read the same parser. + * + * Deliberately nullable (no `.withDefault`): a clean URL means the workspace root, which is + * the only sane default and keeps a shared link short. + */ +export const folderNavParsers = { + folderId: parseAsString, +} as const + +/** + * Opening a folder is a destination, so it lands in the browser history and Back walks out + * of the folder. Defaults clear from the URL to keep shared links short. + */ +export const folderNavUrlKeys = { + history: 'push', + clearOnDefault: true, +} as const diff --git a/apps/sim/app/workspace/[workspaceId]/components/folders/use-folder-navigation.ts b/apps/sim/app/workspace/[workspaceId]/components/folders/use-folder-navigation.ts new file mode 100644 index 00000000000..69628370e6d --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/folders/use-folder-navigation.ts @@ -0,0 +1,102 @@ +'use client' + +import { useCallback, useMemo } from 'react' +import { useQueryStates } from 'nuqs' +import type { ServedFolderResourceType } from '@/lib/api/contracts/folders' +import { + folderNavParsers, + folderNavUrlKeys, +} from '@/app/workspace/[workspaceId]/components/folders/search-params' +import { useFolders } from '@/hooks/queries/folders' +import type { WorkflowFolder } from '@/stores/folders/types' + +export interface UseFolderNavigationOptions { + resourceType: ServedFolderResourceType + workspaceId?: string +} + +export interface FolderNavigation { + /** The open folder, or `null` at the workspace root. */ + currentFolderId: string | null + setCurrentFolderId: (folderId: string | null) => void + /** + * Root-first ancestor chain of the open folder, the open folder last. Empty at the root, + * and empty while the folder list is still loading or when the id no longer resolves (a + * deleted folder or a stale bookmark) — callers fall back to the root listing rather than + * rendering a broken trail. + */ + breadcrumbs: WorkflowFolder[] + /** Every active folder in this resource's tree, as returned by the folders API. */ + folders: WorkflowFolder[] + folderById: Map + isLoading: boolean +} + +const EMPTY_FOLDERS: WorkflowFolder[] = [] + +/** + * URL-backed folder navigation for a foldered resource list. Deliberately + * resourceType-agnostic — the Workflows, Files, Knowledge, and Tables trees are separate + * folder hierarchies over one table, so the caller names its own tree and gets that tree's + * folders, navigation state, and ancestor chain. + * + * The open folder lives in the URL rather than component state because it is shareable, + * bookmarkable, and belongs in the back stack (see `.claude/rules/sim-url-state.md`). + */ +export function useFolderNavigation({ + resourceType, + workspaceId, +}: UseFolderNavigationOptions): FolderNavigation { + const [{ folderId: currentFolderId }, setFolderParams] = useQueryStates( + folderNavParsers, + folderNavUrlKeys + ) + + const { data: folders = EMPTY_FOLDERS, isLoading } = useFolders(workspaceId, { resourceType }) + + const setCurrentFolderId = useCallback( + (folderId: string | null) => { + void setFolderParams({ folderId }) + }, + [setFolderParams] + ) + + const folderById = useMemo(() => { + const byId = new Map() + for (const folder of folders) byId.set(folder.id, folder) + return byId + }, [folders]) + + const breadcrumbs = useMemo(() => { + if (!currentFolderId) return EMPTY_FOLDERS + + /** + * Walks up via `parentId` rather than splitting a materialized path — the generic + * folder table stores no path — and guards against a cycle, which the DB permits + * between constraint checks. An unresolvable link collapses the whole trail so the + * header falls back to the root title instead of rendering a partial path. + */ + const chain: WorkflowFolder[] = [] + const seen = new Set() + let cursor: string | null = currentFolderId + + while (cursor && !seen.has(cursor)) { + seen.add(cursor) + const folder: WorkflowFolder | undefined = folderById.get(cursor) + if (!folder) return EMPTY_FOLDERS + chain.unshift(folder) + cursor = folder.parentId + } + + return chain + }, [currentFolderId, folderById]) + + return { + currentFolderId, + setCurrentFolderId, + breadcrumbs, + folders, + folderById, + isLoading, + } +} diff --git a/apps/sim/app/workspace/[workspaceId]/components/folders/use-folder-row-drag-drop.ts b/apps/sim/app/workspace/[workspaceId]/components/folders/use-folder-row-drag-drop.ts new file mode 100644 index 00000000000..5fa05a555e9 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/folders/use-folder-row-drag-drop.ts @@ -0,0 +1,169 @@ +'use client' + +import { type DragEvent, useCallback, useMemo, useRef, useState } from 'react' +import { parseFolderedRowId } from '@/app/workspace/[workspaceId]/components/folders/folder-row-id' +import type { RowDragDropConfig } from '@/app/workspace/[workspaceId]/components/resource/resource' + +/** + * Private drag payload, namespaced so a drag started on another Sim surface (or an external + * drag) is never mistaken for a foldered list row. + */ +const DRAG_ROW_MIME = 'application/x-sim-foldered-row' + +const DRAG_GHOST_STYLE = + 'position:fixed;top:-500px;left:0;display:inline-flex;align-items:center;padding:4px 10px;background:var(--surface-active);border:1px solid var(--border);border-radius:8px;font-family:system-ui,-apple-system,sans-serif;font-size:13px;color:var(--text-body);white-space:nowrap;pointer-events:none;box-shadow:var(--shadow-medium);z-index:var(--z-toast)' + +/** Shared empty set so an idle drag state keeps a stable identity across renders. */ +const EMPTY_ROW_IDS = new Set() + +export interface UseFolderRowDragDropOptions { + /** Drag and drop are edits; a reader gets neither draggable rows nor drop targets. */ + canEdit: boolean + /** Row currently being renamed inline, which must stay editable rather than draggable. */ + editingRowId: string | null + /** Transitive descendants of each folder, from `buildDescendantIndex`. */ + descendantsByFolderId: Map> + /** Current `parentId` of a folder in this tree, for rejecting a no-op drop. */ + getFolderParentId: (folderId: string) => string | null | undefined + /** Current `folderId` of a resource row, for rejecting a no-op drop. */ + getResourceFolderId: (resourceId: string) => string | null | undefined + /** Label shown in the drag ghost. */ + getRowLabel: (rowId: string) => string + /** Reparents a folder into `targetFolderId`. */ + onMoveFolder: (folderId: string, targetFolderId: string) => void + /** Files a resource into `targetFolderId`. */ + onMoveResource: (resourceId: string, targetFolderId: string) => void +} + +/** + * Drag-a-row-onto-a-folder-row moves for a foldered resource list, shared so Knowledge and + * Tables behave exactly like Files: only folder rows accept a drop, a folder cannot land in + * itself or its own subtree, and a row already sitting directly in the target is a no-op. + * + * Single-row only, which is what the resource lists that use it support. The Files page + * keeps its own configuration because it additionally drags multi-selections and accepts + * external OS file drops. + */ +export function useFolderRowDragDrop({ + canEdit, + editingRowId, + descendantsByFolderId, + getFolderParentId, + getResourceFolderId, + getRowLabel, + onMoveFolder, + onMoveResource, +}: UseFolderRowDragDropOptions): RowDragDropConfig { + const [activeDropTargetId, setActiveDropTargetId] = useState(null) + const [draggedRowIds, setDraggedRowIds] = useState>(() => EMPTY_ROW_IDS) + /** + * The in-flight drag source, mirrored outside React state because `onDragOver` fires far + * faster than a re-render and must decide drop validity against the current source + * synchronously. + */ + const draggedRowIdRef = useRef(null) + const dragGhostRef = useRef(null) + + const optionsRef = useRef({ + descendantsByFolderId, + getFolderParentId, + getResourceFolderId, + getRowLabel, + onMoveFolder, + onMoveResource, + }) + optionsRef.current = { + descendantsByFolderId, + getFolderParentId, + getResourceFolderId, + getRowLabel, + onMoveFolder, + onMoveResource, + } + + const isInvalidDropTarget = useCallback((targetRowId: string, sourceRowId: string) => { + const target = parseFolderedRowId(targetRowId) + if (target.kind !== 'folder') return true + + const source = parseFolderedRowId(sourceRowId) + if (source.kind === 'folder') { + if (source.id === target.id) return true + if (optionsRef.current.descendantsByFolderId.get(source.id)?.has(target.id)) return true + return (optionsRef.current.getFolderParentId(source.id) ?? null) === target.id + } + return (optionsRef.current.getResourceFolderId(source.id) ?? null) === target.id + }, []) + + return useMemo( + () => ({ + activeDropTargetId, + draggedRowIds, + isAnyDragActive: draggedRowIds.size > 0, + isRowDraggable: (rowId) => canEdit && editingRowId !== rowId, + isRowDropTarget: (rowId) => canEdit && parseFolderedRowId(rowId).kind === 'folder', + onDragStart: (e: DragEvent, rowId) => { + if (!canEdit || editingRowId === rowId) { + e.preventDefault() + return + } + + draggedRowIdRef.current = rowId + setDraggedRowIds(new Set([rowId])) + + e.dataTransfer.effectAllowed = 'move' + e.dataTransfer.setData(DRAG_ROW_MIME, rowId) + e.dataTransfer.setData('text/plain', rowId) + + const ghost = document.createElement('div') + ghost.style.cssText = DRAG_GHOST_STYLE + const text = document.createElement('span') + text.style.cssText = 'max-width:200px;overflow:hidden;text-overflow:ellipsis' + text.textContent = optionsRef.current.getRowLabel(rowId) + ghost.appendChild(text) + document.body.appendChild(ghost) + // Force a layout pass so the drag image is measurable before it is captured. + void ghost.offsetHeight + e.dataTransfer.setDragImage(ghost, ghost.offsetWidth / 2, ghost.offsetHeight / 2) + dragGhostRef.current = ghost + }, + onDragOver: (e: DragEvent, rowId) => { + const sourceRowId = draggedRowIdRef.current + if (!sourceRowId || isInvalidDropTarget(rowId, sourceRowId)) return + e.preventDefault() + e.stopPropagation() + e.dataTransfer.dropEffect = 'move' + setActiveDropTargetId(rowId) + }, + onDragLeave: (e: DragEvent, rowId) => { + const relatedTarget = e.relatedTarget + if (relatedTarget instanceof Node && e.currentTarget.contains(relatedTarget)) return + setActiveDropTargetId((current) => (current === rowId ? null : current)) + }, + onDrop: (e: DragEvent, rowId) => { + e.preventDefault() + e.stopPropagation() + setActiveDropTargetId(null) + + const target = parseFolderedRowId(rowId) + if (target.kind !== 'folder') return + + // Prefer the dataTransfer payload over the ref so a drag that started in another + // mount of this page still resolves to a real row id. + const sourceRowId = e.dataTransfer.getData(DRAG_ROW_MIME) || draggedRowIdRef.current + if (!sourceRowId || isInvalidDropTarget(rowId, sourceRowId)) return + + const source = parseFolderedRowId(sourceRowId) + if (source.kind === 'folder') optionsRef.current.onMoveFolder(source.id, target.id) + else optionsRef.current.onMoveResource(source.id, target.id) + }, + onDragEnd: () => { + dragGhostRef.current?.remove() + dragGhostRef.current = null + draggedRowIdRef.current = null + setDraggedRowIds(EMPTY_ROW_IDS) + setActiveDropTargetId(null) + }, + }), + [activeDropTargetId, draggedRowIds, canEdit, editingRowId, isInvalidDropTarget] + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/action-bar/action-bar.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/action-bar/action-bar.tsx index f818286ff26..53ebe27ba84 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/action-bar/action-bar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/action-bar/action-bar.tsx @@ -13,8 +13,8 @@ import { } from '@sim/emcn' import { Download } from '@sim/emcn/icons' import { AnimatePresence, domAnimation, LazyMotion, m } from 'framer-motion' -import type { MoveOptionNode } from '@/app/workspace/[workspaceId]/files/move-options' -import { renderMoveOption } from '@/app/workspace/[workspaceId]/files/move-options' +import type { MoveOptionNode } from '@/app/workspace/[workspaceId]/components/folders' +import { renderMoveOption } from '@/app/workspace/[workspaceId]/components/folders' interface FilesActionBarProps { selectedCount: number 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 9c660dc6f45..1fda2da80d7 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 @@ -16,8 +16,8 @@ import { Pencil, } from '@sim/emcn' import { Download, Link, Pin, Trash } from '@sim/emcn/icons' -import type { MoveOptionNode } from '@/app/workspace/[workspaceId]/files/move-options' -import { renderMoveOption } from '@/app/workspace/[workspaceId]/files/move-options' +import type { MoveOptionNode } from '@/app/workspace/[workspaceId]/components/folders' +import { renderMoveOption } from '@/app/workspace/[workspaceId]/components/folders' interface FileRowContextMenuProps { isOpen: boolean diff --git a/apps/sim/app/workspace/[workspaceId]/files/files.tsx b/apps/sim/app/workspace/[workspaceId]/files/files.tsx index 05d556330b6..bde85ad6454 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/files.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/files.tsx @@ -61,6 +61,7 @@ import { Resource, timeCell, } from '@/app/workspace/[workspaceId]/components' +import type { MoveOptionNode } from '@/app/workspace/[workspaceId]/components/folders' import { FilesActionBar } from '@/app/workspace/[workspaceId]/files/components/action-bar' import { DeleteConfirmModal } from '@/app/workspace/[workspaceId]/files/components/delete-confirm-modal' import { FileRowContextMenu } from '@/app/workspace/[workspaceId]/files/components/file-row-context-menu' @@ -74,7 +75,6 @@ import { } from '@/app/workspace/[workspaceId]/files/components/file-viewer' import { FilesListContextMenu } from '@/app/workspace/[workspaceId]/files/components/files-list-context-menu' import { ShareModal } from '@/app/workspace/[workspaceId]/files/components/share-modal' -import type { MoveOptionNode } from '@/app/workspace/[workspaceId]/files/move-options' import { filesFilterParsers, filesFilterUrlKeys, diff --git a/apps/sim/app/workspace/[workspaceId]/files/move-options.tsx b/apps/sim/app/workspace/[workspaceId]/files/move-options.tsx deleted file mode 100644 index c58d842c5e0..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/files/move-options.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import type { ReactNode } from 'react' -import { - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuSub, - DropdownMenuSubContent, - DropdownMenuSubTrigger, -} from '@sim/emcn' -import { Folder } from '@sim/emcn/icons' - -export interface MoveOptionNode { - value: string - label: string - children: MoveOptionNode[] -} - -export function renderMoveOption( - option: MoveOptionNode, - onMove: (value: string) => void -): ReactNode { - if (option.children.length === 0) { - return ( - onMove(option.value)}> - - {option.label} - - ) - } - return ( - - - - {option.label} - - - onMove(option.value)}>Move here - - {option.children.map((child) => renderMoveOption(child, onMove))} - - - ) -} 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..5364d754044 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,11 @@ const logger = createLogger('CreateBaseModal') interface CreateBaseModalProps { open: boolean onOpenChange: (open: boolean) => void + /** + * Folder the new base is filed under — the folder the list is currently showing, so a + * base created inside a folder appears where the user is looking. `null` is the root. + */ + folderId?: string | null } const STRATEGY_OPTIONS = [ @@ -127,6 +132,7 @@ interface SubmitStatus { export const CreateBaseModal = memo(function CreateBaseModal({ open, onOpenChange, + folderId = null, }: CreateBaseModalProps) { const params = useParams() const workspaceId = params.workspaceId as string @@ -262,6 +268,7 @@ export const CreateBaseModal = memo(function CreateBaseModal({ name: data.name, description: data.description || undefined, workspaceId: workspaceId, + folderId, chunkingConfig: { maxSize: data.maxChunkSize, minSize: data.minChunkSize, diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/components/knowledge-base-context-menu/knowledge-base-context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/components/knowledge-base-context-menu/knowledge-base-context-menu.tsx index 8892875f4f4..b809950a158 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/components/knowledge-base-context-menu/knowledge-base-context-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/components/knowledge-base-context-menu/knowledge-base-context-menu.tsx @@ -6,9 +6,22 @@ import { DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, DropdownMenuTrigger, } from '@sim/emcn' -import { Duplicate, Pencil, Pin, SquareArrowUpRight, TagIcon, Trash } from '@sim/emcn/icons' +import { + Duplicate, + FolderInput, + Pencil, + Pin, + SquareArrowUpRight, + TagIcon, + Trash, +} from '@sim/emcn/icons' +import type { MoveOptionNode } from '@/app/workspace/[workspaceId]/components/folders' +import { renderMoveOptions } from '@/app/workspace/[workspaceId]/components/folders' interface KnowledgeBaseContextMenuProps { isOpen: boolean @@ -22,6 +35,9 @@ interface KnowledgeBaseContextMenuProps { pinned?: boolean onEdit?: () => void onDelete?: () => void + /** Files the base under another folder; the value is a folder id or the root sentinel. */ + onMove?: (optionValue: string) => void + moveOptions?: MoveOptionNode[] showOpenInNewTab?: boolean showViewTags?: boolean showEdit?: boolean @@ -45,6 +61,8 @@ export const KnowledgeBaseContextMenu = memo(function KnowledgeBaseContextMenu({ pinned = false, onEdit, onDelete, + onMove, + moveOptions, showOpenInNewTab = true, showViewTags = true, showEdit = true, @@ -54,7 +72,8 @@ export const KnowledgeBaseContextMenu = memo(function KnowledgeBaseContextMenu({ }: KnowledgeBaseContextMenuProps) { const hasNavigationSection = showOpenInNewTab && !!onOpenInNewTab const hasInfoSection = (showViewTags && !!onViewTags) || !!onCopyId || !!onTogglePin - const hasEditSection = showEdit && !!onEdit + const hasMoveSection = !disableEdit && !!onMove && !!moveOptions && moveOptions.length > 0 + const hasEditSection = (showEdit && !!onEdit) || hasMoveSection const hasDestructiveSection = showDelete && !!onDelete return ( @@ -116,6 +135,18 @@ export const KnowledgeBaseContextMenu = memo(function KnowledgeBaseContextMenu({ )} + {hasMoveSection && ( + + + + Move to + + + {renderMoveOptions(moveOptions!, onMove!)} + + + )} + {hasEditSection && hasDestructiveSection && } {showDelete && onDelete && ( 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..ee95e23bc19 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,26 +2,30 @@ 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 + onAddFolder?: () => void disableAdd?: boolean + disableAddFolder?: boolean } /** * Context menu component for the knowledge base list page. - * Displays "Add knowledge base" option when right-clicking on empty space. + * Displays the create actions when right-clicking on empty space. */ export const KnowledgeListContextMenu = memo(function KnowledgeListContextMenu({ isOpen, position, onClose, onAddKnowledgeBase, + onAddFolder, disableAdd = false, + disableAddFolder = false, }: KnowledgeListContextMenuProps) { return ( !open && onClose()} modal={false}> @@ -51,6 +55,12 @@ export const KnowledgeListContextMenu = memo(function KnowledgeListContextMenu({ Add knowledge base )} + {onAddFolder && ( + + + New folder + + )} ) diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx index 32930a56128..cb7891c3a28 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx @@ -2,14 +2,16 @@ 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, ChipConfirmModal, ChipDropdown, Plus, Tooltip, toast } from '@sim/emcn' +import { Database, FolderPlus } from '@sim/emcn/icons' import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { useParams, useRouter } from 'next/navigation' import { useQueryStates } from 'nuqs' import type { KnowledgeBaseData } from '@/lib/knowledge/types' import { SEARCH_DEBOUNCE_MS } from '@/lib/url-state' import type { + BreadcrumbItem, FilterTag, ResourceAction, ResourceCell, @@ -24,6 +26,20 @@ import { Resource, timeCell, } from '@/app/workspace/[workspaceId]/components' +import type { MoveOptionNode } from '@/app/workspace/[workspaceId]/components/folders' +import { + buildDescendantIndex, + buildMoveOptions, + FolderContextMenu, + folderBreadcrumbItems, + folderRow, + folderRowId, + nextUntitledFolderName, + parseFolderedRowId, + parseMoveOptionValue, + useFolderNavigation, + useFolderRowDragDrop, +} from '@/app/workspace/[workspaceId]/components/folders' import { BaseTagsModal } from '@/app/workspace/[workspaceId]/knowledge/[id]/components' import { CreateBaseModal, @@ -42,13 +58,16 @@ import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/provide 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, useUpdateFolder } from '@/hooks/queries/folders' import { useDeleteKnowledgeBase, useUpdateKnowledgeBase } from '@/hooks/queries/kb/knowledge' import { usePinItem, usePinnedIds, useUnpinItem } from '@/hooks/queries/pinned-items' import { useWorkspaceMembersQuery } from '@/hooks/queries/workspace' import { useDebounce } from '@/hooks/use-debounce' import { useDebouncedSearchSetter } from '@/hooks/use-debounced-search-setter' +import { useInlineRename } from '@/hooks/use-inline-rename' import { usePermissionConfig } from '@/hooks/use-permission-config' import { useUrlSort } from '@/hooks/use-url-sort' +import type { WorkflowFolder } from '@/stores/folders/types' const logger = createLogger('Knowledge') @@ -82,6 +101,9 @@ const CONTENT_FILTER_OPTIONS: ChipDropdownOption[] = [ const FILTER_SECTION_LABEL_CLASS = 'text-[var(--text-muted)] text-small' +const ROOT_BREADCRUMB_LABEL = 'Knowledge Base' +const FOLDER_RESOURCE_TYPE = 'knowledge_base' as const + function connectorCell(connectorTypes?: string[]): ResourceCell { if (!connectorTypes || connectorTypes.length === 0) { return { label: EMPTY_CELL_PLACEHOLDER } @@ -144,7 +166,12 @@ export function Knowledge() { const { knowledgeBases, error } = useKnowledgeBasesList(workspaceId) const { data: members } = useWorkspaceMembersQuery(workspaceId) + /** + * Two pin lookups: a folder pins under `resourceType: 'folder'`, which is a different pin + * namespace from the knowledge bases it contains, so one set cannot answer for both. + */ const pinnedBaseIds = usePinnedIds(workspaceId, 'knowledge_base') + const pinnedFolderIds = usePinnedIds(workspaceId, 'folder') const pinItem = usePinItem() const unpinItem = useUnpinItem() @@ -156,6 +183,15 @@ export function Knowledge() { const { mutateAsync: updateKnowledgeBaseMutation } = useUpdateKnowledgeBase(workspaceId) const { mutateAsync: deleteKnowledgeBaseMutation } = useDeleteKnowledgeBase(workspaceId) + const { currentFolderId, setCurrentFolderId, breadcrumbs, folders } = useFolderNavigation({ + resourceType: FOLDER_RESOURCE_TYPE, + workspaceId, + }) + + const createFolder = useCreateFolder() + const updateFolder = useUpdateFolder() + const deleteFolder = useDeleteFolderMutation() + const [ { search: urlSearchQuery, @@ -205,6 +241,16 @@ export function Knowledge() { const [isTagsModalOpen, setIsTagsModalOpen] = useState(false) const [isDeleting, setIsDeleting] = useState(false) + const [activeFolder, setActiveFolder] = useState(null) + const [folderPendingDelete, setFolderPendingDelete] = useState(null) + + const { + isOpen: isFolderContextMenuOpen, + position: folderContextMenuPosition, + handleContextMenu: handleFolderCtxMenu, + closeMenu: closeFolderContextMenu, + } = useContextMenu() + const { isOpen: isListContextMenuOpen, position: listContextMenuPosition, @@ -222,12 +268,77 @@ export function Knowledge() { const isRowContextMenuOpenRef = useRef(isRowContextMenuOpen) isRowContextMenuOpenRef.current = isRowContextMenuOpen + const isFolderContextMenuOpenRef = useRef(isFolderContextMenuOpen) + isFolderContextMenuOpenRef.current = isFolderContextMenuOpen + const knowledgeBasesRef = useRef(knowledgeBases) knowledgeBasesRef.current = knowledgeBases const activeKnowledgeBaseRef = useRef(activeKnowledgeBase) activeKnowledgeBaseRef.current = activeKnowledgeBase + const activeFolderRef = useRef(activeFolder) + activeFolderRef.current = activeFolder + + const foldersRef = useRef(folders) + foldersRef.current = folders + + const currentFolderIdRef = useRef(currentFolderId) + currentFolderIdRef.current = currentFolderId + + /** + * Renames both kinds of row through one multiplexed session — the row id already encodes + * which kind it is, so the table's `editing` cell wiring stays identical for folders and + * knowledge bases. A duplicate sibling name is a 409 from the folder API; the mutations + * below surface it and `useInlineRename` keeps the edit session open so the user can pick + * another name. + */ + const listRename = useInlineRename({ + onSave: async (rowId, name) => { + const parsed = parseFolderedRowId(rowId) + if (parsed.kind === 'folder') { + try { + return await updateFolder.mutateAsync({ + workspaceId, + resourceType: FOLDER_RESOURCE_TYPE, + id: parsed.id, + updates: { name }, + }) + } catch (renameError) { + toast.error(getErrorMessage(renameError, 'Failed to rename folder')) + throw renameError + } + } + return updateKnowledgeBaseMutation({ + knowledgeBaseId: parsed.id, + updates: { name }, + }) + }, + }) + + const listRenameRef = useRef(listRename) + listRenameRef.current = listRename + + /** Renames the open folder from its breadcrumb crumb, where it has no row to edit. */ + const breadcrumbRename = useInlineRename({ + onSave: async (folderId, name) => { + try { + return await updateFolder.mutateAsync({ + workspaceId, + resourceType: FOLDER_RESOURCE_TYPE, + id: folderId, + updates: { name }, + }) + } catch (renameError) { + toast.error(getErrorMessage(renameError, 'Failed to rename folder')) + throw renameError + } + }, + }) + + const breadcrumbRenameRef = useRef(breadcrumbRename) + breadcrumbRenameRef.current = breadcrumbRename + const handleContentContextMenu = useCallback( (e: React.MouseEvent) => { const target = e.target as HTMLElement @@ -265,8 +376,44 @@ export function Knowledge() { [deleteKnowledgeBaseMutation] ) + /** + * Folders in the open folder, sorted independently of the bases below them. + * + * With no explicit sort the two blocks disagree on purpose — folders read best + * alphabetically while bases read best most-recently-updated-first — which mirrors the + * Files page. The resource filters (connectors/content/owner) describe properties a folder + * does not have, so folders answer only to the search term. + */ + const visibleFolders = useMemo(() => { + const siblings = folders.filter((folder) => (folder.parentId ?? null) === currentFolderId) + const needle = debouncedSearchQuery.trim().toLowerCase() + const searched = needle + ? siblings.filter((folder) => folder.name.toLowerCase().includes(needle)) + : siblings + + const col = activeSort?.column ?? 'name' + const dir = activeSort?.direction ?? 'asc' + return [...searched].sort((a, b) => { + const aPinned = pinnedFolderIds.has(a.id) + const bPinned = pinnedFolderIds.has(b.id) + if (aPinned !== bPinned) return aPinned ? -1 : 1 + + let cmp = 0 + if (col === 'created') { + cmp = a.createdAt.getTime() - b.createdAt.getTime() + } else if (col === 'updated') { + cmp = a.updatedAt.getTime() - b.updatedAt.getTime() + } else { + cmp = a.name.localeCompare(b.name) + } + return dir === 'asc' ? cmp : -cmp + }) + }, [folders, currentFolderId, debouncedSearchQuery, activeSort, pinnedFolderIds]) + 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) => { @@ -331,6 +478,7 @@ export function Knowledge() { }) }, [ knowledgeBases, + currentFolderId, debouncedSearchQuery, connectorFilter, contentFilter, @@ -340,53 +488,118 @@ export function Knowledge() { pinnedBaseIds, ]) - const rows: ResourceRow[] = useMemo( - () => - processedKBs.map((kb) => { - const kbWithCount = kb as KnowledgeBaseWithDocCount - return { - id: kb.id, - cells: { - name: { - icon: KNOWLEDGE_BASE_ICON, - label: kb.name, - }, - documents: { - label: String(kbWithCount.docCount || 0), - }, - tokens: { - label: kb.tokenCount ? kb.tokenCount.toLocaleString() : '0', + const baseRows: ResourceRow[] = useMemo(() => { + const folderRows = visibleFolders.map((folder) => + folderRow(folder, { + cells: { + 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), + }, + }) + ) + + const knowledgeBaseRows = processedKBs.map((kb) => { + const kbWithCount = kb as KnowledgeBaseWithDocCount + return { + id: kb.id, + cells: { + name: { + icon: KNOWLEDGE_BASE_ICON, + label: kb.name, + }, + documents: { + label: String(kbWithCount.docCount || 0), + }, + tokens: { + label: kb.tokenCount ? kb.tokenCount.toLocaleString() : '0', + }, + connectors: connectorCell(kb.connectorTypes), + created: timeCell(kb.createdAt), + owner: ownerCell(kb.userId, members), + updated: timeCell(kb.updatedAt), + }, + } + }) + + return [...folderRows, ...knowledgeBaseRows] + }, [visibleFolders, processedKBs, members]) + + /** + * Rename is layered over the built rows rather than folded into the builder above, so a + * keystroke in the rename field does not rebuild every row's cells. + */ + 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: { + ...row.cells.name, + editing: { + value: listRename.editValue, + onChange: listRename.setEditValue, + onSubmit: listRename.submitRename, + onCancel: listRename.cancelRename, + disabled: listRename.isSaving, }, - connectors: connectorCell(kb.connectorTypes), - created: timeCell(kb.createdAt), - owner: ownerCell(kb.userId, members), - updated: timeCell(kb.updatedAt), }, - } - }), - [processedKBs, members] - ) + }, + } + }) + }, [ + baseRows, + listRename.editingId, + listRename.editValue, + listRename.isSaving, + listRename.setEditValue, + listRename.submitRename, + listRename.cancelRename, + ]) 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 (listRenameRef.current.editingId === rowId) return + + const parsed = parseFolderedRowId(rowId) + if (parsed.kind === 'folder') { + setCurrentFolderId(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/${parsed.id}?${urlParams.toString()}`) }, - [router, workspaceId] + [router, workspaceId, setCurrentFolderId] ) const handleRowContextMenu = useCallback( (e: React.MouseEvent, rowId: string) => { - const kb = knowledgeBasesRef.current.find((k) => k.id === rowId) as + const parsed = parseFolderedRowId(rowId) + if (parsed.kind === 'folder') { + const folder = foldersRef.current.find((item) => item.id === parsed.id) + if (!folder) return + setActiveFolder(folder) + 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 () => { @@ -429,22 +642,198 @@ export function Knowledge() { setIsEditModalOpen(true) }, []) - const handleTogglePin = useCallback(() => { + const handleDelete = useCallback(() => { + setIsDeleteModalOpen(true) + }, []) + + const canEdit = userPermissions.canEdit === true + + const handleCreateFolder = useCallback(async () => { + if (!workspaceId) return + const parentId = currentFolderIdRef.current + const name = nextUntitledFolderName(foldersRef.current, parentId) + + try { + const folder = await createFolder.mutateAsync({ + workspaceId, + resourceType: FOLDER_RESOURCE_TYPE, + name, + parentId: parentId ?? undefined, + }) + // Drop straight into rename: the auto-generated name is a placeholder, and the user + // should not have to hunt for a second action to replace it. + listRenameRef.current.startRename(folderRowId(folder.id), folder.name) + } catch (createError) { + logger.error('Failed to create folder', createError) + toast.error(getErrorMessage(createError, 'Failed to create folder')) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [workspaceId]) + + const handleRenameFolder = useCallback(() => { + const folder = activeFolderRef.current + if (!folder) return + listRenameRef.current.startRename(folderRowId(folder.id), folder.name) + }, []) + + const handleOpenFolder = useCallback(() => { + const folder = activeFolderRef.current + if (folder) setCurrentFolderId(folder.id) + }, [setCurrentFolderId]) + + const handleCopyFolderId = useCallback(() => { + const folder = activeFolderRef.current + if (folder) navigator.clipboard.writeText(folder.id) + }, []) + + const handleRequestFolderDelete = useCallback(() => { + setFolderPendingDelete(activeFolderRef.current) + }, []) + + const folderPendingDeleteRef = useRef(folderPendingDelete) + folderPendingDeleteRef.current = folderPendingDelete + + const handleConfirmFolderDelete = useCallback(async () => { + const folder = folderPendingDeleteRef.current + if (!folder) return + try { + await deleteFolder.mutateAsync({ + workspaceId, + resourceType: FOLDER_RESOURCE_TYPE, + id: folder.id, + }) + setFolderPendingDelete(null) + setActiveFolder(null) + // Deleting the folder you are standing in leaves the list pointed at an archived + // folder, which renders as an empty page with a dead breadcrumb — step out to its + // parent instead. + if (currentFolderIdRef.current === folder.id) { + setCurrentFolderId(folder.parentId) + } + } catch (deleteError) { + logger.error('Failed to delete folder', deleteError) + toast.error(getErrorMessage(deleteError, 'Failed to delete folder')) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [workspaceId, setCurrentFolderId]) + + const descendantsByFolderId = useMemo(() => buildDescendantIndex(folders), [folders]) + + const handleToggleBasePin = useCallback(() => { const kb = activeKnowledgeBaseRef.current if (!kb) return const mutation = pinnedBaseIds.has(kb.id) ? unpinItem : pinItem mutation.mutate({ workspaceId, resourceType: 'knowledge_base', resourceId: kb.id }) closeRowContextMenu() + // eslint-disable-next-line react-hooks/exhaustive-deps -- mutation objects are unstable; mutate is stable in v5 }, [workspaceId, pinnedBaseIds, closeRowContextMenu]) - const handleDelete = useCallback(() => { - setIsDeleteModalOpen(true) - }, []) + const handleToggleFolderPin = useCallback(() => { + const folder = activeFolderRef.current + if (!folder) return + const mutation = pinnedFolderIds.has(folder.id) ? unpinItem : pinItem + mutation.mutate({ workspaceId, resourceType: 'folder', resourceId: folder.id }) + closeFolderContextMenu() + // eslint-disable-next-line react-hooks/exhaustive-deps -- mutation objects are unstable; mutate is stable in v5 + }, [workspaceId, pinnedFolderIds, closeFolderContextMenu]) + + /** Move targets for the folder under the cursor: itself and its subtree are unreachable. */ + const folderMoveOptions: MoveOptionNode[] = useMemo(() => { + if (!activeFolder) return [] + const excluded = new Set([activeFolder.id]) + for (const id of descendantsByFolderId.get(activeFolder.id) ?? []) excluded.add(id) + return buildMoveOptions({ + folders, + rootLabel: ROOT_BREADCRUMB_LABEL, + excludedFolderIds: excluded, + }) + }, [folders, activeFolder, descendantsByFolderId]) - const canEdit = userPermissions.canEdit === true + /** Move targets for a knowledge base: every folder, since a base has no subtree. */ + const knowledgeBaseMoveOptions: MoveOptionNode[] = useMemo( + () => buildMoveOptions({ folders, rootLabel: ROOT_BREADCRUMB_LABEL }), + [folders] + ) + + /** Shared by the "Move to" submenu and by dropping a folder row onto another folder. */ + const moveFolderTo = useCallback( + async (folderId: string, parentId: string | null) => { + try { + await updateFolder.mutateAsync({ + workspaceId, + resourceType: FOLDER_RESOURCE_TYPE, + id: folderId, + updates: { parentId }, + }) + } catch (moveError) { + logger.error('Failed to move folder', moveError) + toast.error(getErrorMessage(moveError, 'Failed to move folder')) + } + }, + // eslint-disable-next-line react-hooks/exhaustive-deps -- mutation objects are unstable; mutateAsync is stable in v5 + [workspaceId] + ) + + /** Shared by the "Move to" submenu and by dropping a base row onto a folder. */ + const moveKnowledgeBaseTo = useCallback( + async (knowledgeBaseId: string, folderId: string | null) => { + try { + await updateKnowledgeBaseMutation({ knowledgeBaseId, updates: { folderId } }) + } catch (moveError) { + logger.error('Failed to move knowledge base', moveError) + } + }, + [updateKnowledgeBaseMutation] + ) + + const handleMoveFolder = useCallback( + async (optionValue: string) => { + const folder = activeFolderRef.current + if (!folder) return + const parentId = parseMoveOptionValue(optionValue) + if ((folder.parentId ?? null) !== parentId) await moveFolderTo(folder.id, parentId) + closeFolderContextMenu() + }, + [moveFolderTo, closeFolderContextMenu] + ) + + const handleMoveKnowledgeBase = useCallback( + async (optionValue: string) => { + const kb = activeKnowledgeBaseRef.current + if (!kb) return + const folderId = parseMoveOptionValue(optionValue) + if ((kb.folderId ?? null) !== folderId) await moveKnowledgeBaseTo(kb.id, folderId) + closeRowContextMenu() + }, + [moveKnowledgeBaseTo, closeRowContextMenu] + ) + + const rowDragDropConfig = useFolderRowDragDrop({ + canEdit, + editingRowId: listRename.editingId, + descendantsByFolderId, + getFolderParentId: (folderId) => foldersRef.current.find((f) => f.id === folderId)?.parentId, + getResourceFolderId: (knowledgeBaseId) => + knowledgeBasesRef.current.find((kb) => kb.id === knowledgeBaseId)?.folderId ?? null, + getRowLabel: (rowId) => { + const parsed = parseFolderedRowId(rowId) + return parsed.kind === 'folder' + ? (foldersRef.current.find((f) => f.id === parsed.id)?.name ?? 'Folder') + : (knowledgeBasesRef.current.find((kb) => kb.id === parsed.id)?.name ?? 'Knowledge base') + }, + onMoveFolder: (folderId, targetFolderId) => void moveFolderTo(folderId, targetFolderId), + onMoveResource: (knowledgeBaseId, targetFolderId) => + void moveKnowledgeBaseTo(knowledgeBaseId, targetFolderId), + }) const headerActions: ResourceAction[] = useMemo( () => [ + { + text: 'New folder', + icon: FolderPlus, + onSelect: handleCreateFolder, + disabled: createFolder.isPending || !canEdit, + }, { text: 'New base', icon: Plus, @@ -453,7 +842,53 @@ export function Knowledge() { variant: 'primary', }, ], - [handleOpenCreateModal, canEdit] + [handleOpenCreateModal, handleCreateFolder, createFolder.isPending, canEdit] + ) + + const listBreadcrumbs: BreadcrumbItem[] = useMemo( + () => + folderBreadcrumbItems({ + rootLabel: ROOT_BREADCRUMB_LABEL, + rootIcon: Database, + breadcrumbs, + onNavigate: setCurrentFolderId, + currentFolderEditing: + breadcrumbRename.editingId && breadcrumbRename.editingId === currentFolderId + ? { + isEditing: true, + value: breadcrumbRename.editValue, + onChange: breadcrumbRenameRef.current.setEditValue, + onSubmit: breadcrumbRenameRef.current.submitRename, + onCancel: breadcrumbRenameRef.current.cancelRename, + disabled: breadcrumbRename.isSaving, + } + : undefined, + currentFolderActions: + canEdit && breadcrumbs.length > 0 + ? [ + { + label: 'Rename', + onClick: () => { + const folder = breadcrumbs[breadcrumbs.length - 1] + breadcrumbRenameRef.current.startRename(folder.id, folder.name) + }, + }, + { + label: 'Delete', + onClick: () => setFolderPendingDelete(breadcrumbs[breadcrumbs.length - 1]), + }, + ] + : undefined, + }), + [ + breadcrumbs, + currentFolderId, + setCurrentFolderId, + canEdit, + breadcrumbRename.editingId, + breadcrumbRename.editValue, + breadcrumbRename.isSaving, + ] ) const searchConfig: SearchConfig = useMemo( @@ -614,7 +1049,12 @@ export function Knowledge() { return ( <> - + @@ -634,7 +1075,9 @@ export function Knowledge() { position={listContextMenuPosition} onClose={closeListContextMenu} onAddKnowledgeBase={handleOpenCreateModal} + onAddFolder={handleCreateFolder} disableAdd={!canEdit} + disableAddFolder={createFolder.isPending || !canEdit} /> {activeKnowledgeBase && ( @@ -645,10 +1088,12 @@ export function Knowledge() { onOpenInNewTab={handleOpenInNewTab} onViewTags={handleViewTags} onCopyId={handleCopyId} - onTogglePin={handleTogglePin} + onTogglePin={handleToggleBasePin} pinned={pinnedBaseIds.has(activeKnowledgeBase.id)} onEdit={handleEdit} onDelete={handleDelete} + onMove={handleMoveKnowledgeBase} + moveOptions={knowledgeBaseMoveOptions} showOpenInNewTab showViewTags showEdit @@ -658,6 +1103,43 @@ export function Knowledge() { /> )} + {activeFolder && ( + + )} + + { + if (!open) setFolderPendingDelete(null) + }} + srTitle='Delete folder' + title='Delete folder' + text={[ + 'Are you sure you want to delete ', + { text: folderPendingDelete?.name ?? 'this folder', bold: true }, + '? This also deletes the knowledge bases and folders inside it. You can restore them from Recently Deleted in Settings.', + ]} + confirm={{ + label: 'Delete', + onClick: handleConfirmFolderDelete, + pending: deleteFolder.isPending, + pendingLabel: 'Deleting...', + }} + /> + {activeKnowledgeBase && ( )} - + ) } 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 e6df13416ca..b62d3a912e8 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 @@ -8,6 +8,7 @@ import { formatDate } from '@sim/utils/formatting' import { useParams, useRouter } from 'next/navigation' import { useQueryStates } from 'nuqs' import { canMutateWorkspaceSettingsSection } from '@/components/settings/navigation' +import type { ServedFolderResourceType } from '@/lib/api/contracts/folders' import { type ColumnOption, SortDropdown } from '@/app/workspace/[workspaceId]/components' import { RESOURCE_REGISTRY } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry' import type { MothershipResourceType } from '@/app/workspace/[workspaceId]/home/types' @@ -44,6 +45,8 @@ type ResourceType = | 'file' | 'folder' | 'workspace_folder' + | 'knowledge_folder' + | 'table_folder' | 'chat' function getResourceHref( @@ -65,6 +68,10 @@ function getResourceHref( return `${base}/w` case 'workspace_folder': return `${base}/files?folderId=${id}` + case 'knowledge_folder': + return `${base}/knowledge?folderId=${id}` + case 'table_folder': + return `${base}/tables?folderId=${id}` case 'chat': return `${base}/chat/${id}` } @@ -82,6 +89,8 @@ const RESOURCE_TYPE_TO_MOTHERSHIP: Record, Mothersh workflow: 'workflow', folder: 'folder', workspace_folder: 'filefolder', + knowledge_folder: 'folder', + table_folder: 'folder', table: 'table', knowledge: 'knowledgebase', file: 'file', @@ -115,6 +124,8 @@ const TYPE_LABEL: Record, string> = { workflow: 'Workflow', folder: 'Folder', workspace_folder: 'File Folder', + knowledge_folder: 'Knowledge Folder', + table_folder: 'Table Folder', table: 'Table', knowledge: 'Knowledge Base', file: 'File', @@ -130,10 +141,31 @@ function ResourceIcon({ resource }: { resource: DeletedResource }) { ) } +/** + * Folder trees served by the generic folders API, other than the workflow tree that owns the + * standalone "Folders" tab. Each entry names the deleted-resource type it produces, the + * `resourceType` its rows are read and restored under, and the tab it files beneath — a + * foldered resource's folders belong with the resource, the way a file folder sits under + * Files. Declared once so adding a foldered resource is one row here rather than a fourth + * copy of the query/collect/restore triple below. + */ +const FOLDERED_RESOURCE_TREES = [ + { type: 'knowledge_folder', resourceType: 'knowledge_base', tab: 'knowledge' }, + { type: 'table_folder', resourceType: 'table', tab: 'table' }, +] as const satisfies readonly { + type: Exclude + resourceType: ServedFolderResourceType + tab: ResourceType +}[] + +const FOLDER_TREE_TAB_BY_TYPE = new Map( + FOLDERED_RESOURCE_TREES.map((tree) => [tree.type, tree.tab]) +) + function matchesActiveTab(resource: DeletedResource, activeTab: ResourceType): boolean { if (activeTab === 'all') return true if (activeTab === 'file') return resource.type === 'file' || resource.type === 'workspace_folder' - return resource.type === activeTab + return resource.type === activeTab || FOLDER_TREE_TAB_BY_TYPE.get(resource.type) === activeTab } export function RecentlyDeleted() { @@ -170,6 +202,15 @@ export function RecentlyDeleted() { const activeFoldersQuery = useFolders(workspaceId) const tablesQuery = useTablesList(workspaceId, 'archived') const knowledgeQuery = useKnowledgeBasesQuery(workspaceId, { scope: 'archived' }) + /** + * One archived-folder query per non-workflow tree, in the fixed order of + * {@link FOLDERED_RESOURCE_TREES} so the hook call order stays stable. + */ + const knowledgeFoldersQuery = useFolders(workspaceId, { + scope: 'archived', + resourceType: 'knowledge_base', + }) + const tableFoldersQuery = useFolders(workspaceId, { scope: 'archived', resourceType: 'table' }) const filesQuery = useWorkspaceFiles(workspaceId, 'archived') const workspaceFoldersQuery = useWorkspaceFileFolders(workspaceId, 'archived') const chatsQuery = useMothershipChats(workspaceId, { scope: 'archived' }) @@ -187,6 +228,8 @@ export function RecentlyDeleted() { foldersQuery.isLoading || tablesQuery.isLoading || knowledgeQuery.isLoading || + knowledgeFoldersQuery.isLoading || + tableFoldersQuery.isLoading || filesQuery.isLoading || workspaceFoldersQuery.isLoading || chatsQuery.isLoading @@ -196,6 +239,8 @@ export function RecentlyDeleted() { foldersQuery.error || tablesQuery.error || knowledgeQuery.error || + knowledgeFoldersQuery.error || + tableFoldersQuery.error || filesQuery.error || workspaceFoldersQuery.error || chatsQuery.error @@ -243,6 +288,19 @@ export function RecentlyDeleted() { }) } + const folderTreeData = [knowledgeFoldersQuery.data, tableFoldersQuery.data] + FOLDERED_RESOURCE_TREES.forEach((tree, index) => { + for (const folder of folderTreeData[index] ?? []) { + items.push({ + id: folder.id, + name: folder.name, + type: tree.type, + deletedAt: folder.deletedAt ? new Date(folder.deletedAt) : new Date(folder.updatedAt), + workspaceId: folder.workspaceId, + }) + } + }) + for (const f of filesQuery.data ?? []) { items.push({ id: f.id, @@ -280,6 +338,8 @@ export function RecentlyDeleted() { foldersQuery.data, tablesQuery.data, knowledgeQuery.data, + knowledgeFoldersQuery.data, + tableFoldersQuery.data, filesQuery.data, workspaceFoldersQuery.data, chatsQuery.data, @@ -380,6 +440,17 @@ export function RecentlyDeleted() { folderId: resource.id, }) break + case 'knowledge_folder': + case 'table_folder': { + const tree = FOLDERED_RESOURCE_TREES.find((entry) => entry.type === resource.type) + if (!tree) break + await restoreFolder.mutateAsync({ + folderId: resource.id, + workspaceId: resource.workspaceId, + resourceType: tree.resourceType, + }) + break + } case 'chat': await restoreChat.mutateAsync(resource.id) 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 b0a97888a2f..c5360fec673 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 @@ -5,10 +5,15 @@ import { DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, DropdownMenuTrigger, Upload, } from '@sim/emcn' -import { Database, Download, Duplicate, Pencil, Pin, Trash } from '@sim/emcn/icons' +import { Database, Download, Duplicate, FolderInput, Pencil, Pin, Trash } from '@sim/emcn/icons' +import type { MoveOptionNode } from '@/app/workspace/[workspaceId]/components/folders' +import { renderMoveOptions } from '@/app/workspace/[workspaceId]/components/folders' interface TableContextMenuProps { isOpen: boolean @@ -23,6 +28,9 @@ interface TableContextMenuProps { onRename?: () => void onImportCsv?: () => void onExportCsv?: () => void + /** Files the table under another folder; the value is a folder id or the root sentinel. */ + onMove?: (optionValue: string) => void + moveOptions?: MoveOptionNode[] disableDelete?: boolean disableRename?: boolean disableImport?: boolean @@ -42,6 +50,8 @@ export function TableContextMenu({ onRename, onImportCsv, onExportCsv, + onMove, + moveOptions, disableDelete = false, disableRename = false, disableImport = false, @@ -93,7 +103,18 @@ export function TableContextMenu({ Export CSV )} - {(onViewSchema || onRename || onImportCsv || onExportCsv) && + {onMove && moveOptions && moveOptions.length > 0 && ( + + + + Move to + + + {renderMoveOptions(moveOptions, onMove)} + + + )} + {(onViewSchema || onRename || onImportCsv || onExportCsv || onMove) && (onCopyId || onTogglePin || onDelete) && } {onTogglePin && ( 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..1454dec9146 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 @@ -7,15 +7,17 @@ import { DropdownMenuTrigger, Upload, } from '@sim/emcn' -import { Plus } from '@sim/emcn/icons' +import { FolderPlus, Plus } 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,8 +26,10 @@ export function TablesListContextMenu({ position, onClose, onCreateTable, + onCreateFolder, onUploadCsv, disableCreate = false, + disableCreateFolder = false, disableUpload = false, }: TablesListContextMenuProps) { return ( @@ -56,6 +60,12 @@ export function TablesListContextMenu({ Create table )} + {onCreateFolder && ( + + + New folder + + )} {onUploadCsv && ( diff --git a/apps/sim/app/workspace/[workspaceId]/tables/loading.tsx b/apps/sim/app/workspace/[workspaceId]/tables/loading.tsx index b6e01e9d0aa..570e3ef3888 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/loading.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/loading.tsx @@ -1,7 +1,7 @@ 'use client' import { Plus, Upload } from '@sim/emcn' -import { Table as TableIcon } from '@sim/emcn/icons' +import { FolderPlus, Table as TableIcon } from '@sim/emcn/icons' import { type ChromeActionSpec, ResourceChromeFallback, @@ -18,6 +18,7 @@ const COLUMNS = [ const ACTIONS: ChromeActionSpec[] = [ { text: 'Import CSV', icon: Upload }, + { text: 'New folder', icon: FolderPlus }, { text: 'New table', icon: Plus, variant: 'primary' }, ] diff --git a/apps/sim/app/workspace/[workspaceId]/tables/prefetch.ts b/apps/sim/app/workspace/[workspaceId]/tables/prefetch.ts index f5f9d2a41a8..6eacb992359 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/prefetch.ts +++ b/apps/sim/app/workspace/[workspaceId]/tables/prefetch.ts @@ -1,26 +1,44 @@ import type { QueryClient } from '@tanstack/react-query' +import type { FolderApi } from '@/lib/api/contracts/folders' import type { TableDefinition } from '@/lib/table' import { prefetchInternalJson } from '@/app/workspace/[workspaceId]/lib/prefetch-internal-fetch' +import { FOLDER_LIST_STALE_TIME, mapFolder } from '@/hooks/queries/folders' +import { folderKeys } from '@/hooks/queries/utils/folder-keys' import { TABLE_LIST_STALE_TIME, tableKeys } from '@/hooks/queries/utils/table-keys' /** - * Prefetches the workspace's tables list under the same query key the client - * `useTablesList` hook uses (scope `active`), so the list paints populated on - * first render. + * Prefetches the workspace's tables list and its table folder tree under the same + * query keys the client `useTablesList` / `useFolders` hooks use (scope `active`), + * so the list paints populated on first render. Both are needed: a table row is + * only placed correctly relative to the folder rows it sits beside, so + * prefetching one without the other still flashes an ungrouped list. * * Table definitions carry `Date` fields, so the list goes through the * `/api/table` route and caches the serialized wire shape — see - * {@link prefetchInternalJson}. + * {@link prefetchInternalJson}. Folders are mapped with the same `mapFolder` the + * hook applies so the hydrated entry matches a client fetch exactly. */ export async function prefetchTables(queryClient: QueryClient, workspaceId: string): Promise { - await queryClient.prefetchQuery({ - queryKey: tableKeys.list(workspaceId, 'active'), - queryFn: async () => { - const response = await prefetchInternalJson<{ data: { tables: TableDefinition[] } }>( - `/api/table?workspaceId=${workspaceId}&scope=active` - ) - return response.data.tables - }, - staleTime: TABLE_LIST_STALE_TIME, - }) + await Promise.all([ + queryClient.prefetchQuery({ + queryKey: tableKeys.list(workspaceId, 'active'), + queryFn: async () => { + const response = await prefetchInternalJson<{ data: { tables: TableDefinition[] } }>( + `/api/table?workspaceId=${workspaceId}&scope=active` + ) + return response.data.tables + }, + staleTime: TABLE_LIST_STALE_TIME, + }), + queryClient.prefetchQuery({ + queryKey: folderKeys.list(workspaceId, 'active', 'table'), + queryFn: async () => { + const { folders } = await prefetchInternalJson<{ folders?: FolderApi[] }>( + `/api/folders?workspaceId=${workspaceId}&scope=active&resourceType=table` + ) + return (folders ?? []).map(mapFolder) + }, + staleTime: FOLDER_LIST_STALE_TIME, + }), + ]) } diff --git a/apps/sim/app/workspace/[workspaceId]/tables/search-params.ts b/apps/sim/app/workspace/[workspaceId]/tables/search-params.ts index a1c75373053..5b2ed7df480 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/search-params.ts +++ b/apps/sim/app/workspace/[workspaceId]/tables/search-params.ts @@ -32,6 +32,12 @@ export const tablesSortParams = createSortParams(TABLE_SORT_COLUMNS, { * Selecting a table navigates to the `tables/[tableId]` route (via `router`), * so the active table is route state, not query state, and is intentionally not * represented here. + * + * The open folder is `?folderId=`, declared once for every foldered surface in + * `components/folders/search-params.ts` and read through `useFolderNavigation`. + * It is deliberately not part of this map: folder navigation is a destination + * (`history: 'push'`), while everything here is a filter write that must not + * churn the back stack. */ export const tablesParsers = { search: parseAsString.withDefault(''), diff --git a/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx b/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx index f2bd07ec6a4..328ac0edc9c 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx @@ -3,8 +3,9 @@ import { 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 { Columns3, FolderPlus, Rows3, Table as TableIcon } from '@sim/emcn/icons' import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { useParams, useRouter } from 'next/navigation' import { useQueryStates } from 'nuqs' @@ -12,6 +13,7 @@ import type { TableDefinition } from '@/lib/table' import { CSV_ASYNC_IMPORT_THRESHOLD_BYTES, generateUniqueTableName } from '@/lib/table/constants' import { SEARCH_DEBOUNCE_MS } from '@/lib/url-state' import type { + DropdownOption, FilterTag, ResourceAction, ResourceColumn, @@ -19,7 +21,26 @@ import type { SearchConfig, SortConfig, } from '@/app/workspace/[workspaceId]/components' -import { ownerCell, Resource, timeCell } from '@/app/workspace/[workspaceId]/components' +import { + EMPTY_CELL_PLACEHOLDER, + ownerCell, + Resource, + timeCell, +} from '@/app/workspace/[workspaceId]/components' +import type { MoveOptionNode } from '@/app/workspace/[workspaceId]/components/folders' +import { + buildDescendantIndex, + buildMoveOptions, + FolderContextMenu, + folderBreadcrumbItems, + folderRow, + folderRowId, + nextUntitledFolderName, + parseFolderedRowId, + parseMoveOptionValue, + useFolderNavigation, + useFolderRowDragDrop, +} from '@/app/workspace/[workspaceId]/components/folders' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import { ImportCsvDialog, @@ -33,6 +54,7 @@ import { tablesUrlKeys, } from '@/app/workspace/[workspaceId]/tables/search-params' import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks' +import { useCreateFolder, useDeleteFolderMutation, useUpdateFolder } from '@/hooks/queries/folders' import { usePinItem, usePinnedIds, useUnpinItem } from '@/hooks/queries/pinned-items' import { cancelTableJob, @@ -40,16 +62,18 @@ import { useCreateTable, useDeleteTable, useImportCsvAsync, + useMoveTable, useRenameTable, useTablesList, useUploadCsvToTable, } from '@/hooks/queries/tables' -import { useWorkspaceMembersQuery } from '@/hooks/queries/workspace' +import { useWorkspaceMembersQuery, type WorkspaceMember } from '@/hooks/queries/workspace' import { useDebounce } from '@/hooks/use-debounce' import { useDebouncedSearchSetter } from '@/hooks/use-debounced-search-setter' import { useInlineRename } from '@/hooks/use-inline-rename' import { usePermissionConfig } from '@/hooks/use-permission-config' import { useUrlSort } from '@/hooks/use-url-sort' +import type { WorkflowFolder } from '@/stores/folders/types' import { useImportTrayStore } from '@/stores/table/import-tray/store' const logger = createLogger('Tables') @@ -63,6 +87,16 @@ const COLUMNS: ResourceColumn[] = [ { id: 'updated', header: 'Last Updated' }, ] +/** Root label for breadcrumbs and the "move to workspace root" destination. */ +const ROOT_LABEL = 'Tables' + +const EMPTY_TABLES: TableDefinition[] = [] + +/** The right-clicked row, resolved to the entity it refers to. */ +type TableResourceItem = + | { kind: 'table'; table: TableDefinition } + | { kind: 'folder'; folder: WorkflowFolder } + export function Tables() { const params = useParams() const router = useRouter() @@ -76,29 +110,89 @@ export function Tables() { }, [permissionConfig.hideTablesTab, router, workspaceId]) const userPermissions = useUserPermissionsContext() + const canEdit = userPermissions.canEdit === true - const { data: tables = [], error } = useTablesList(workspaceId) + const { data: tables = EMPTY_TABLES, error } = useTablesList(workspaceId) const { data: members } = useWorkspaceMembersQuery(workspaceId) const pinnedTableIds = usePinnedIds(workspaceId, 'table') + // Folder pins live in their own `resourceType` namespace, so a page listing + // folders alongside tables resolves two sets. + const pinnedFolderIds = usePinnedIds(workspaceId, 'folder') const pinItem = usePinItem() const unpinItem = useUnpinItem() + const { + currentFolderId, + setCurrentFolderId, + breadcrumbs: folderChain, + folders, + folderById, + isLoading: foldersLoading, + } = useFolderNavigation({ + resourceType: 'table', + workspaceId, + }) + if (error) { logger.error('Failed to load tables:', error) } const deleteTable = useDeleteTable(workspaceId) const renameTable = useRenameTable(workspaceId) const createTable = useCreateTable(workspaceId) + const moveTable = useMoveTable(workspaceId) const uploadCsv = useUploadCsvToTable() const importCsvAsync = useImportCsvAsync() + const createFolder = useCreateFolder() + const updateFolder = useUpdateFolder() + const deleteFolder = useDeleteFolderMutation() - const tableRename = useInlineRename({ - onSave: (tableId, name) => renameTable.mutateAsync({ tableId, name }), + const membersById = useMemo(() => { + const map = new Map() + for (const member of members ?? []) map.set(member.userId, member) + return map + }, [members]) + + /** + * One rename session multiplexed over both row kinds — the shared `Resource` + * table has a single editing cell, so the id it carries has to resolve to + * either a folder or a table. Both mutations toast their own failure; the hook + * restores the original name and keeps the field open. + */ + const listRename = useInlineRename({ + onSave: (rowId, name) => { + const parsed = parseFolderedRowId(rowId) + if (parsed.kind === 'folder') { + return updateFolder + .mutateAsync({ + workspaceId, + resourceType: 'table', + id: parsed.id, + updates: { name }, + }) + .catch((err: unknown) => { + toast.error(getErrorMessage(err, 'Failed to rename folder'), { duration: 5000 }) + throw err + }) + } + return renameTable.mutateAsync({ tableId: parsed.id, name }) + }, + }) + + const breadcrumbRename = useInlineRename({ + onSave: (folderId, name) => + updateFolder + .mutateAsync({ workspaceId, resourceType: 'table', id: folderId, updates: { name } }) + .catch((err: unknown) => { + toast.error(getErrorMessage(err, 'Failed to rename folder'), { duration: 5000 }) + throw err + }), }) const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false) + const [isDeleteFolderDialogOpen, setIsDeleteFolderDialogOpen] = useState(false) const [isImportDialogOpen, setIsImportDialogOpen] = useState(false) const [activeTable, setActiveTable] = useState(null) + const [activeFolder, setActiveFolder] = useState(null) const [{ search: urlSearchTerm, rows: rowCountFilter, owner: ownerFilter }, setTableFilters] = useQueryStates(tablesParsers, tablesUrlKeys) @@ -134,6 +228,9 @@ export function Tables() { const uploading = uploadProgress.total > 0 const csvInputRef = useRef(null) + const tablesRef = useRef(tables) + tablesRef.current = tables + const { isOpen: isListContextMenuOpen, position: listContextMenuPosition, @@ -148,9 +245,59 @@ export function Tables() { closeMenu: closeRowContextMenu, } = useContextMenu() + const [contextMenuKind, setContextMenuKind] = useState<'table' | 'folder'>('table') + + /** + * Descendants of every folder, so a move destination that sits inside the moved folder can + * be excluded — reparenting a folder under its own child would close a cycle (the server + * rejects it; this keeps it out of the menu, and out of a valid drop target, entirely). + */ + const descendantFolderIds = useMemo(() => buildDescendantIndex(folders), [folders]) + + const visibleFolders = useMemo(() => { + const siblings = folders.filter((folder) => (folder.parentId ?? null) === currentFolderId) + const needle = debouncedSearchTerm.trim().toLowerCase() + const searched = needle + ? siblings.filter((folder) => folder.name.toLowerCase().includes(needle)) + : siblings + + return [...searched].sort((a, b) => { + // Pinned folders float to the top of every sort/direction — pinning is a + // user-declared priority, not another sort key to be inverted by `desc`. + const aPinned = pinnedFolderIds.has(a.id) + const bPinned = pinnedFolderIds.has(b.id) + if (aPinned !== bPinned) return aPinned ? -1 : 1 + + // Folders carry none of the table-specific columns, so `columns`/`rows`/ + // `owner` fall back to name rather than producing an arbitrary order. + let cmp = 0 + if (sortColumn === 'created') { + cmp = a.createdAt.getTime() - b.createdAt.getTime() + } else if (sortColumn === 'updated') { + cmp = a.updatedAt.getTime() - b.updatedAt.getTime() + } else { + cmp = a.name.localeCompare(b.name) + } + return sortDirection === 'asc' ? cmp : -cmp + }) + }, [folders, currentFolderId, debouncedSearchTerm, sortColumn, sortDirection, pinnedFolderIds]) + const processedTables = useMemo(() => { const query = debouncedSearchTerm.trim().toLowerCase() - let result = query ? tables.filter((t) => t.name.toLowerCase().includes(query)) : tables + /** + * A `folderId` that no longer names an active folder — restored on its own out + * of Recently Deleted while its folder stayed archived — would otherwise match + * no level at all and leave the table unreachable from every view. Fall it back + * to the root instead. Skipped while the folder list is still loading, when an + * empty index would transiently drag every table to the root. + */ + let result = tables.filter((t) => { + const folderId = t.folderId ?? null + const effectiveFolderId = + foldersLoading || !folderId || folderById.has(folderId) ? folderId : null + return effectiveFolderId === currentFolderId + }) + if (query) result = result.filter((t) => t.name.toLowerCase().includes(query)) if (rowCountFilter.length > 0) { result = result.filter((t) => { @@ -188,8 +335,8 @@ 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 } @@ -198,32 +345,42 @@ export function Tables() { }) }, [ tables, + currentFolderId, + folderById, + foldersLoading, debouncedSearchTerm, rowCountFilter, ownerFilter, sortColumn, sortDirection, - members, + membersById, pinnedTableIds, ]) - const rows: ResourceRow[] = useMemo( - () => - processedTables.map((table) => ({ + /** + * Folders first, then tables — folders are containers, so keeping them above + * the leaves survives every sort column and direction. + */ + const baseRows: ResourceRow[] = useMemo(() => { + const folderRows = visibleFolders.map((folder) => + folderRow(folder, { + cells: { + 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): ResourceRow => ({ id: table.id, 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: , @@ -234,19 +391,98 @@ export function Tables() { label: String(table.rowCount), }, created: timeCell(table.createdAt), - owner: ownerCell(table.createdBy, members), + owner: ownerCell(table.createdBy, membersById), updated: timeCell(table.updatedAt), }, - })), - [ - processedTables, - members, - tableRename.editingId, - tableRename.editValue, - tableRename.setEditValue, - tableRename.submitRename, - tableRename.cancelRename, + }) + ) + + return [...folderRows, ...tableRows] + }, [visibleFolders, processedTables, membersById]) + + /** + * Layered on top of {@link baseRows} rather than folded into it so a keystroke + * in the rename field rebuilds one cell instead of every row's cells. + */ + 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: { + ...row.cells.name, + editing: { + value: listRename.editValue, + onChange: listRename.setEditValue, + onSubmit: listRename.submitRename, + onCancel: listRename.cancelRename, + disabled: listRename.isSaving, + }, + }, + }, + } + }) + }, [ + baseRows, + listRename.editingId, + listRename.editValue, + listRename.isSaving, + listRename.setEditValue, + listRename.submitRename, + listRename.cancelRename, + ]) + + const startFolderRename = useCallback( + (folder: WorkflowFolder) => listRename.startRename(folderRowId(folder.id), folder.name), + [listRename.startRename] + ) + + const currentFolderActions: DropdownOption[] | undefined = useMemo(() => { + if (!currentFolderId) return undefined + const folder = folderById.get(currentFolderId) + if (!folder) return undefined + return [ + { + label: 'Rename', + disabled: !canEdit, + onClick: () => breadcrumbRename.startRename(folder.id, folder.name), + }, ] + }, [currentFolderId, folderById, canEdit, breadcrumbRename.startRename]) + + const currentFolderEditing = useMemo(() => { + if (!currentFolderId || breadcrumbRename.editingId !== currentFolderId) return undefined + return { + isEditing: true, + value: breadcrumbRename.editValue, + onChange: breadcrumbRename.setEditValue, + onSubmit: breadcrumbRename.submitRename, + onCancel: breadcrumbRename.cancelRename, + disabled: breadcrumbRename.isSaving, + } + }, [ + currentFolderId, + breadcrumbRename.editingId, + breadcrumbRename.editValue, + breadcrumbRename.isSaving, + breadcrumbRename.setEditValue, + breadcrumbRename.submitRename, + breadcrumbRename.cancelRename, + ]) + + const breadcrumbs = useMemo( + () => + folderBreadcrumbItems({ + breadcrumbs: folderChain, + rootLabel: ROOT_LABEL, + onNavigate: setCurrentFolderId, + currentFolderActions, + currentFolderEditing, + }), + [folderChain, setCurrentFolderId, currentFolderActions, currentFolderEditing] ) const searchConfig: SearchConfig = useMemo( @@ -291,10 +527,9 @@ export function Tables() { const ownerDisplayLabel = useMemo(() => { if (ownerFilter.length === 0) return 'All' - if (ownerFilter.length === 1) - return members?.find((m) => m.userId === ownerFilter[0])?.name ?? '1 member' + if (ownerFilter.length === 1) return membersById.get(ownerFilter[0])?.name ?? '1 member' return `${ownerFilter.length} members` - }, [ownerFilter, members]) + }, [ownerFilter, membersById]) const memberOptions: ComboboxOption[] = useMemo( () => @@ -381,6 +616,8 @@ export function Tables() { rowCountDisplayLabel, ownerDisplayLabel, hasActiveFilters, + setRowCountFilter, + setOwnerFilter, ] ) @@ -397,12 +634,12 @@ export function Tables() { if (ownerFilter.length > 0) { const label = ownerFilter.length === 1 - ? `Owner: ${members?.find((m) => m.userId === ownerFilter[0])?.name ?? '1 member'}` + ? `Owner: ${membersById.get(ownerFilter[0])?.name ?? '1 member'}` : `Owner: ${ownerFilter.length} members` tags.push({ label, onRemove: () => setOwnerFilter([]) }) } return tags - }, [rowCountFilter, ownerFilter, members]) + }, [rowCountFilter, ownerFilter, membersById, setRowCountFilter, setOwnerFilter]) const handleContentContextMenu = useCallback( (e: React.MouseEvent) => { @@ -420,28 +657,115 @@ export function Tables() { const handleRowClick = useCallback( (rowId: string) => { - if (!isRowContextMenuOpen) { - router.push(`/workspace/${workspaceId}/tables/${rowId}`) + if (isRowContextMenuOpen || listRename.editingId === rowId) return + const parsed = parseFolderedRowId(rowId) + if (parsed.kind === 'folder') { + setCurrentFolderId(parsed.id) + return + } + router.push(`/workspace/${workspaceId}/tables/${parsed.id}`) + }, + [isRowContextMenuOpen, listRename.editingId, router, workspaceId, setCurrentFolderId] + ) + + const resolveRowItem = useCallback( + (rowId: string): TableResourceItem | null => { + const parsed = parseFolderedRowId(rowId) + if (parsed.kind === 'folder') { + const folder = folderById.get(parsed.id) + return folder ? { kind: 'folder', folder } : null } + const table = tables.find((t) => t.id === parsed.id) + return table ? { kind: 'table', table } : null }, - [isRowContextMenuOpen, router, workspaceId] + [folderById, tables] ) const handleRowContextMenu = useCallback( (e: React.MouseEvent, rowId: string) => { - const table = tables.find((t) => t.id === rowId) ?? null - setActiveTable(table) + const item = resolveRowItem(rowId) + if (!item) return + if (item.kind === 'folder') { + setActiveFolder(item.folder) + setActiveTable(null) + setContextMenuKind('folder') + } else { + setActiveTable(item.table) + setActiveFolder(null) + setContextMenuKind('table') + } handleRowCtxMenu(e) }, - [tables, handleRowCtxMenu] + [resolveRowItem, handleRowCtxMenu] ) - const handleTogglePin = useCallback(() => { - if (!activeTable) return - const mutation = pinnedTableIds.has(activeTable.id) ? unpinItem : pinItem - mutation.mutate({ workspaceId, resourceType: 'table', resourceId: activeTable.id }) - closeRowContextMenu() - }, [workspaceId, activeTable, pinnedTableIds, closeRowContextMenu]) + const tableMoveOptions: MoveOptionNode[] = useMemo( + () => buildMoveOptions({ folders, rootLabel: ROOT_LABEL }), + [folders] + ) + + const folderMoveOptions: MoveOptionNode[] = useMemo(() => { + if (!activeFolder) return [] + const excluded = new Set([activeFolder.id]) + for (const id of descendantFolderIds.get(activeFolder.id) ?? []) excluded.add(id) + return buildMoveOptions({ folders, rootLabel: ROOT_LABEL, excludedFolderIds: excluded }) + }, [activeFolder, folders, descendantFolderIds]) + + const handleMoveTable = useCallback( + (optionValue: string) => { + if (!activeTable) return + const folderId = parseMoveOptionValue(optionValue) + if ((activeTable.folderId ?? null) === folderId) return + moveTable.mutate({ tableId: activeTable.id, folderId }) + closeRowContextMenu() + }, + // eslint-disable-next-line react-hooks/exhaustive-deps -- mutation objects are unstable; mutate is stable in v5 + [activeTable, closeRowContextMenu] + ) + + /** Shared by the "Move to" submenu and by dropping a folder row onto another folder. */ + const moveFolderTo = useCallback( + (folderId: string, parentId: string | null) => { + updateFolder.mutate( + { workspaceId, resourceType: 'table', id: folderId, updates: { parentId } }, + { + onError: (err) => + toast.error(getErrorMessage(err, 'Failed to move folder'), { duration: 5000 }), + } + ) + }, + // eslint-disable-next-line react-hooks/exhaustive-deps -- mutation objects are unstable; mutate is stable in v5 + [workspaceId] + ) + + const handleMoveFolder = useCallback( + (optionValue: string) => { + if (!activeFolder) return + const parentId = parseMoveOptionValue(optionValue) + if ((activeFolder.parentId ?? null) === parentId) return + moveFolderTo(activeFolder.id, parentId) + closeRowContextMenu() + }, + [activeFolder, moveFolderTo, closeRowContextMenu] + ) + + const rowDragDropConfig = useFolderRowDragDrop({ + canEdit, + editingRowId: listRename.editingId, + descendantsByFolderId: descendantFolderIds, + getFolderParentId: (folderId) => folderById.get(folderId)?.parentId ?? null, + getResourceFolderId: (tableId) => + tablesRef.current.find((table) => table.id === tableId)?.folderId ?? null, + getRowLabel: (rowId) => { + const parsed = parseFolderedRowId(rowId) + return parsed.kind === 'folder' + ? (folderById.get(parsed.id)?.name ?? 'Folder') + : (tablesRef.current.find((table) => table.id === parsed.id)?.name ?? 'Table') + }, + onMoveFolder: (folderId, targetFolderId) => moveFolderTo(folderId, targetFolderId), + onMoveResource: (tableId, targetFolderId) => + moveTable.mutate({ tableId, folderId: targetFolderId }), + }) const handleDelete = async () => { if (!activeTable) return @@ -454,6 +778,51 @@ export function Tables() { } } + const handleTogglePin = useCallback(() => { + const target = + contextMenuKind === 'folder' + ? activeFolder && { resourceType: 'folder' as const, id: activeFolder.id } + : activeTable && { resourceType: 'table' as const, id: activeTable.id } + if (!target) return + const pinned = + target.resourceType === 'folder' + ? pinnedFolderIds.has(target.id) + : pinnedTableIds.has(target.id) + const mutation = pinned ? unpinItem : pinItem + mutation.mutate({ workspaceId, resourceType: target.resourceType, resourceId: target.id }) + closeRowContextMenu() + // eslint-disable-next-line react-hooks/exhaustive-deps -- mutation objects are unstable; mutate is stable in v5 + }, [ + workspaceId, + contextMenuKind, + activeFolder, + activeTable, + pinnedFolderIds, + pinnedTableIds, + closeRowContextMenu, + ]) + + const handleDeleteFolder = async () => { + if (!activeFolder) return + try { + await deleteFolder.mutateAsync({ + workspaceId, + resourceType: 'table', + id: activeFolder.id, + }) + // The open folder just disappeared — fall back to its parent rather than + // leaving a `?folderId=` pointing at an archived folder. + if (currentFolderId === activeFolder.id) { + setCurrentFolderId(activeFolder.parentId) + } + setIsDeleteFolderDialogOpen(false) + setActiveFolder(null) + } catch (err) { + logger.error('Failed to delete folder:', err) + toast.error(getErrorMessage(err, 'Failed to delete folder'), { duration: 5000 }) + } + } + const handleCsvChange = useCallback( async (e: React.ChangeEvent) => { const list = e.target.files @@ -489,6 +858,7 @@ export function Tables() { try { const result = await importCsvAsync.mutateAsync({ workspaceId, + folderId: currentFolderId, file, onProgress: (percent) => { useImportTrayStore.getState().setUploadPercent(pendingId, percent) @@ -519,7 +889,11 @@ export function Tables() { for (let i = 0; i < syncFiles.length; i++) { const file = syncFiles[i] try { - const result = await uploadCsv.mutateAsync({ workspaceId, file }) + const result = await uploadCsv.mutateAsync({ + workspaceId, + folderId: currentFolderId, + file, + }) if (syncFiles.length === 1 && asyncFiles.length === 0) { const tableId = result?.data?.table?.id @@ -553,7 +927,7 @@ export function Tables() { } }, // eslint-disable-next-line react-hooks/exhaustive-deps -- mutation objects are unstable; mutateAsync is stable in v5 - [workspaceId, router] + [workspaceId, currentFolderId, router] ) const handleListUploadCsv = useCallback(() => { @@ -574,6 +948,7 @@ export function Tables() { try { const result = await createTableAsync({ name, + folderId: currentFolderId, schema: { columns: [{ name: 'name', type: 'string' }], }, @@ -586,7 +961,23 @@ export function Tables() { } catch (err) { logger.error('Failed to create table:', err) } - }, [tables, router, workspaceId, createTableAsync]) + }, [tables, router, workspaceId, currentFolderId, createTableAsync]) + + const createFolderAsync = createFolder.mutateAsync + const handleCreateFolder = useCallback(async () => { + try { + const folder = await createFolderAsync({ + workspaceId, + resourceType: 'table', + name: nextUntitledFolderName(folders, currentFolderId), + parentId: currentFolderId ?? undefined, + }) + startFolderRename(folder) + } catch (err) { + logger.error('Failed to create folder:', err) + toast.error(getErrorMessage(err, 'Failed to create folder'), { duration: 5000 }) + } + }, [workspaceId, folders, currentFolderId, createFolderAsync, startFolderRename]) const headerActions: ResourceAction[] = useMemo( () => [ @@ -594,21 +985,29 @@ 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: !canEdit || createFolder.isPending, }, { 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, handleCreateTable, + createFolder.isPending, createTable.isPending, ] ) @@ -623,7 +1022,8 @@ export function Tables() { @@ -636,6 +1036,7 @@ export function Tables() { @@ -656,23 +1057,23 @@ 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) }} - onTogglePin={handleTogglePin} - pinned={activeTable ? pinnedTableIds.has(activeTable.id) : false} onDelete={() => setIsDeleteDialogOpen(true)} onRename={() => { - if (activeTable) tableRename.startRename(activeTable.id, activeTable.name) + if (activeTable) listRename.startRename(activeTable.id, activeTable.name) }} onImportCsv={() => setIsImportDialogOpen(true)} onExportCsv={async () => { @@ -684,9 +1085,35 @@ export function Tables() { toast.error('Failed to export table') } }} - disableDelete={userPermissions.canEdit !== true} - disableRename={userPermissions.canEdit !== true} - disableImport={userPermissions.canEdit !== true} + onTogglePin={handleTogglePin} + pinned={activeTable ? pinnedTableIds.has(activeTable.id) : false} + onMove={canEdit ? handleMoveTable : undefined} + moveOptions={canEdit ? tableMoveOptions : undefined} + disableDelete={!canEdit} + disableRename={!canEdit} + disableImport={!canEdit} + /> + + { + if (activeFolder) setCurrentFolderId(activeFolder.id) + closeRowContextMenu() + }} + onRename={() => { + if (activeFolder) startFolderRename(activeFolder) + }} + onCopyId={() => { + if (activeFolder) navigator.clipboard.writeText(activeFolder.id) + }} + onDelete={() => setIsDeleteFolderDialogOpen(true)} + onTogglePin={handleTogglePin} + pinned={activeFolder ? pinnedFolderIds.has(activeFolder.id) : false} + onMove={canEdit ? handleMoveFolder : undefined} + moveOptions={canEdit ? folderMoveOptions : undefined} + canEdit={canEdit} /> {activeTable && ( @@ -723,6 +1150,29 @@ export function Tables() { pendingLabel: 'Deleting...', }} /> + + { + setIsDeleteFolderDialogOpen(open) + if (!open) setActiveFolder(null) + }} + srTitle='Delete Folder' + title='Delete Folder' + text={[ + 'Are you sure you want to delete ', + { text: activeFolder?.name ?? 'this folder', bold: true }, + '? ', + { text: 'Every table and subfolder inside it will be deleted too.', error: true }, + ' You can restore those tables from Recently Deleted in Settings.', + ]} + confirm={{ + label: 'Delete', + onClick: handleDeleteFolder, + pending: deleteFolder.isPending, + pendingLabel: 'Deleting...', + }} + /> ) } diff --git a/apps/sim/background/cleanup-soft-deletes.ts b/apps/sim/background/cleanup-soft-deletes.ts index d486195cb5e..419a98496bd 100644 --- a/apps/sim/background/cleanup-soft-deletes.ts +++ b/apps/sim/background/cleanup-soft-deletes.ts @@ -417,12 +417,18 @@ const CLEANUP_TARGETS = [ softDeleteCol: folderTable.deletedAt, wsCol: folderTable.workspaceId, /** - * `folder` is shared by all four resource types, but file folders are still written to - * `workspace_file_folders` and this pass must not hard-delete rows it does not own. One - * widened predicate rather than a target per type: same table, same soft-delete column, - * same workspace scoping — splitting it would only multiply the batched scans. + * `folder` is shared by all four resource types, every one of which now writes here. + * The predicate is kept (rather than dropped) so a resource type added to the enum + * before its cutover lands is not silently hard-deleted by this pass. One widened + * predicate rather than a target per type: same table, same soft-delete column, same + * workspace scoping — splitting it would only multiply the batched scans. */ - additionalPredicate: inArray(folderTable.resourceType, ['workflow', 'knowledge_base', 'table']), + additionalPredicate: inArray(folderTable.resourceType, [ + 'workflow', + 'file', + 'knowledge_base', + 'table', + ]), name: 'folder', }, { diff --git a/apps/sim/ee/workspace-forking/lib/mapping/resources.ts b/apps/sim/ee/workspace-forking/lib/mapping/resources.ts index 1df83dc9bcb..362849b5e10 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 as folderTable, 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: folderTable.name, }) .from(workspaceFiles) .leftJoin( - workspaceFileFolder, + folderTable, and( - eq(workspaceFiles.folderId, workspaceFileFolder.id), - isNull(workspaceFileFolder.deletedAt) + eq(workspaceFiles.folderId, folderTable.id), + eq(folderTable.resourceType, 'file'), + isNull(folderTable.deletedAt) ) ) .where( diff --git a/apps/sim/hooks/queries/folders.ts b/apps/sim/hooks/queries/folders.ts index c82f65416cc..13bdee285b3 100644 --- a/apps/sim/hooks/queries/folders.ts +++ b/apps/sim/hooks/queries/folders.ts @@ -13,6 +13,7 @@ import { type ServedFolderResourceType, 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,6 +21,7 @@ import { createOptimisticMutationHandlers, generateTempId, } from '@/hooks/queries/utils/optimistic-mutation' +import { tableKeys } from '@/hooks/queries/utils/table-keys' import { getTopInsertionSortOrder } from '@/hooks/queries/utils/top-insertion-sort-order' import { getWorkflows } from '@/hooks/queries/utils/workflow-cache' import type { WorkflowFolder } from '@/stores/folders/types' @@ -129,6 +131,36 @@ interface DuplicateFolderVariables { newId?: string } +/** + * Refreshes the lists that a folder delete/restore cascade rewrote. + * + * The cascade archives or restores the resources inside the folder subtree, so + * the folder tree alone going stale is not enough — the resource list that + * renders those rows has to refetch too. Each resource type owns a different + * cache, hence the switch; a type with no list surface yet is a no-op. + */ +function invalidateCascadedResourceLists( + queryClient: ReturnType, + resourceType: ServedFolderResourceType, + workspaceId: string +): Promise | void { + 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() }) + /** + * `file` has no case: the Files page reads and writes its folders through + * `/api/workspaces/[id]/files/folders/**`, which owns its own invalidation, so a + * cascade never reaches this hook for that tree. + */ + default: + return + } +} + /** * Creates optimistic mutation handlers for folder operations */ @@ -270,8 +302,7 @@ export function useDeleteFolderMutation() { onSettled: (_data, _error, variables) => { const resourceType = variables.resourceType ?? 'workflow' queryClient.invalidateQueries({ queryKey: folderKeys.resource(resourceType) }) - if (resourceType !== 'workflow') return - return invalidateWorkflowLists(queryClient, variables.workspaceId, ['active', 'archived']) + return invalidateCascadedResourceLists(queryClient, resourceType, variables.workspaceId) }, }) } @@ -299,8 +330,7 @@ export function useRestoreFolder() { onSettled: (_data, _error, variables) => { const resourceType = variables.resourceType ?? 'workflow' queryClient.invalidateQueries({ queryKey: folderKeys.resource(resourceType) }) - if (resourceType !== 'workflow') return - return invalidateWorkflowLists(queryClient, variables.workspaceId, ['active', 'archived']) + return invalidateCascadedResourceLists(queryClient, resourceType, variables.workspaceId) }, }) } diff --git a/apps/sim/hooks/queries/kb/knowledge.ts b/apps/sim/hooks/queries/kb/knowledge.ts index f31a3e00154..4fb7d4ce1aa 100644 --- a/apps/sim/hooks/queries/kb/knowledge.ts +++ b/apps/sim/hooks/queries/kb/knowledge.ts @@ -607,6 +607,8 @@ interface CreateKnowledgeBaseParams { name: string description?: string workspaceId: string + /** Folder to create the knowledge base in; `null`/omitted creates it at the workspace root. */ + folderId?: string | null chunkingConfig: { maxSize: number minSize: number @@ -643,6 +645,8 @@ interface UpdateKnowledgeBaseParams { name?: string description?: string workspaceId?: string | null + /** Moves the knowledge base between folders; `null` moves it to the workspace root. */ + folderId?: string | null } } @@ -663,7 +667,29 @@ export function useUpdateKnowledgeBase(workspaceId?: string) { return useMutation({ mutationFn: updateKnowledgeBase, - onError: (error) => { + /** + * A folder move re-parents a row the user is looking at, so the list is patched up + * front — otherwise the base lingers in the folder it just left until the refetch + * lands. Only the folder is applied optimistically; name/description edits already + * happen behind a modal that closes on success. + */ + onMutate: async ({ knowledgeBaseId, updates }) => { + if (updates.folderId === undefined) return + await queryClient.cancelQueries({ queryKey: knowledgeKeys.lists() }) + const previous = queryClient.getQueriesData({ + queryKey: knowledgeKeys.lists(), + }) + queryClient.setQueriesData({ queryKey: knowledgeKeys.lists() }, (old) => + old?.map((kb) => + kb.id === knowledgeBaseId ? { ...kb, folderId: updates.folderId ?? null } : kb + ) + ) + return { previous } + }, + onError: (error, _variables, context) => { + for (const [key, data] of context?.previous ?? []) { + queryClient.setQueryData(key, data) + } toast.error(error.message, { duration: 5000 }) }, onSettled: (_data, _error, { knowledgeBaseId }) => { diff --git a/apps/sim/hooks/queries/tables.ts b/apps/sim/hooks/queries/tables.ts index 49a84c6252f..78595ef2e06 100644 --- a/apps/sim/hooks/queries/tables.ts +++ b/apps/sim/hooks/queries/tables.ts @@ -639,6 +639,46 @@ export function useUpdateTableLocks(workspaceId: string) { /** * Delete a table from a workspace. */ +/** + * Move a table into a folder, or to the workspace root with `folderId: null`. + * + * Optimistically repoints `folderId` in the cached active list so the row leaves + * the current folder the instant the move is issued; the list is the only surface + * that renders folder placement, so no other cache entry needs patching. + */ +export function useMoveTable(workspaceId: string) { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({ tableId, folderId }: { tableId: string; folderId: string | null }) => { + return requestJson(updateTableContract, { + params: { tableId }, + body: { workspaceId, folderId }, + }) + }, + onMutate: async ({ tableId, folderId }) => { + const listKey = tableKeys.list(workspaceId, 'active') + await queryClient.cancelQueries({ queryKey: listKey }) + const snapshot = queryClient.getQueryData(listKey) + queryClient.setQueryData(listKey, (old) => + old?.map((table) => (table.id === tableId ? { ...table, folderId } : table)) + ) + return { snapshot } + }, + onError: (error, _variables, context) => { + if (context?.snapshot) { + queryClient.setQueryData(tableKeys.list(workspaceId, 'active'), context.snapshot) + } + if (isValidationError(error)) return + toast.error(error.message, { duration: 5000 }) + }, + onSettled: (_data, _error, { tableId }) => { + queryClient.invalidateQueries({ queryKey: tableKeys.lists() }) + queryClient.invalidateQueries({ queryKey: tableKeys.detail(tableId), exact: true }) + }, + }) +} + export function useDeleteTable(workspaceId: string) { const queryClient = useQueryClient() @@ -1554,6 +1594,8 @@ export function useRestoreTable() { interface UploadCsvParams { workspaceId: string + /** Folder to create the imported table in; omitted imports to the workspace root. */ + folderId?: string | null file: File } @@ -1565,11 +1607,13 @@ export function useUploadCsvToTable() { const timezone = useTimezone() return useMutation({ - mutationFn: async ({ workspaceId, file }: UploadCsvParams) => { + mutationFn: async ({ workspaceId, folderId, file }: UploadCsvParams) => { // Text fields must precede the file part: the server parses the body as a - // stream and needs workspaceId before it reaches the (large) file. + // stream and resolves as soon as it reaches the file, so any field appended + // after it is never seen. const formData = new FormData() formData.append('workspaceId', workspaceId) + if (folderId) formData.append('folderId', folderId) formData.append('timezone', timezone) formData.append('file', file) @@ -1604,6 +1648,8 @@ export function useUploadCsvToTable() { interface ImportCsvAsyncParams { workspaceId: string + /** Folder to create the imported table in; omitted imports to the workspace root. */ + folderId?: string | null file: File onProgress?: (percent: number) => void } @@ -1636,10 +1682,10 @@ export function useImportCsvAsync() { const queryClient = useQueryClient() const timezone = useTimezone() return useMutation({ - mutationFn: async ({ workspaceId, file, onProgress }: ImportCsvAsyncParams) => { + mutationFn: async ({ workspaceId, folderId, file, onProgress }: ImportCsvAsyncParams) => { const fileKey = await uploadCsvToWorkspaceStorage(file, workspaceId, onProgress) const response = await requestJson(importTableAsyncContract, { - body: { workspaceId, fileKey, fileName: file.name, timezone }, + body: { workspaceId, folderId, fileKey, fileName: file.name, timezone }, }) return response.data }, diff --git a/apps/sim/lib/api/contracts/folders.ts b/apps/sim/lib/api/contracts/folders.ts index ec2e6317a32..a94257aee26 100644 --- a/apps/sim/lib/api/contracts/folders.ts +++ b/apps/sim/lib/api/contracts/folders.ts @@ -6,13 +6,13 @@ export const folderResourceTypeSchema = z.enum(['workflow', 'file', 'knowledge_b export type FolderResourceType = z.output /** - * The resource types the generic folder engine actually serves. `file` is excluded on - * purpose: file folders still write the legacy `workspace_file_folders` table, so accepting - * the value here would answer "you have no folders" against a surface that plainly does. - * It gains an entry when the file cutover lands. + * The resource types the generic folder engine serves. Identical to + * {@link folderResourceTypeSchema} now that every type is backed by the `folder` table; + * kept as a separate schema so a future resource type can be added to the enum (and the + * DB) ahead of the routes that serve it. */ export const servedFolderResourceTypeSchema = z - .enum(['workflow', 'knowledge_base', 'table']) + .enum(['workflow', 'file', 'knowledge_base', 'table']) .default('workflow') export type ServedFolderResourceType = z.output diff --git a/apps/sim/lib/api/contracts/knowledge/base.ts b/apps/sim/lib/api/contracts/knowledge/base.ts index cf2c8f67b66..701073e3a23 100644 --- a/apps/sim/lib/api/contracts/knowledge/base.ts +++ b/apps/sim/lib/api/contracts/knowledge/base.ts @@ -60,6 +60,11 @@ export const createKnowledgeBaseBodySchema = z.object({ ) .optional(), workspaceId: z.string().min(1, 'Workspace ID is required'), + /** + * Folder the knowledge base is created in, from the `knowledge_base` folder tree. + * `null` (or omitted) creates it at the workspace root. + */ + folderId: z.string().min(1, 'Folder ID cannot be empty').nullable().optional(), embeddingModel: z.literal('text-embedding-3-small').default('text-embedding-3-small'), embeddingDimension: z.literal(1536).default(1536), chunkingConfig: chunkingConfigSchema.default({ @@ -77,6 +82,11 @@ export const updateKnowledgeBaseBodySchema = createKnowledgeBaseBodySchema .partial() .extend({ chunkingConfig: chunkingConfigSchema.optional(), + /** + * Moves the knowledge base between folders. Omitted leaves the folder untouched; + * explicit `null` moves it back to the workspace root. + */ + folderId: z.string().min(1, 'Folder ID cannot be empty').nullable().optional(), workspaceId: z.string().nullable().optional(), embeddingModel: z.literal('text-embedding-3-small').optional(), embeddingDimension: z.literal(1536).optional(), @@ -106,6 +116,7 @@ export const knowledgeBaseDataSchema = z updatedAt: wireDateSchema, deletedAt: nullableWireDateSchema, workspaceId: z.string().nullable(), + folderId: z.string().nullable(), 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 index 6d4434d9198..385e228cf99 100644 --- a/apps/sim/lib/api/contracts/pinned-items.ts +++ b/apps/sim/lib/api/contracts/pinned-items.ts @@ -6,6 +6,11 @@ import { defineRouteContract } from '@/lib/api/contracts/types' * Mirrors `pinnedItem.resourceType` in `packages/db/schema.ts`, which is deliberately * plain `text` on that table rather than a `pgEnum` — the set of pinnable kinds is * expected to grow, and this schema is the enforcement point. + * + * `folder` covers every folder tree at once: folder ids are globally unique, so one pin + * namespace serves the file, knowledge-base, and table trees. It is separate from the + * resource's own type, which is why a page listing folders alongside its resources resolves + * two `usePinnedIds` sets. */ export const pinnedResourceTypeSchema = z.enum([ 'workflow', diff --git a/apps/sim/lib/api/contracts/primitives.ts b/apps/sim/lib/api/contracts/primitives.ts index cf7fffe80f1..a0bfff57299 100644 --- a/apps/sim/lib/api/contracts/primitives.ts +++ b/apps/sim/lib/api/contracts/primitives.ts @@ -59,6 +59,17 @@ export const organizationIdSchema = requiredFieldSchema('Organization ID is requ /** Non-empty `workflowId` field with a stable, human-readable message. */ export const workflowIdSchema = requiredFieldSchema('Workflow ID is required') +/** + * A `folder.id` value. Not `.uuid()`: the column is free-form `text` and the + * legacy `workflow_folder` rows migrated onto it keep their original id shape. + * Callers that also allow "no folder" chain `.nullable()` themselves so the + * two-state and three-state spellings stay explicit at each call site. + */ +export const folderIdSchema = requiredFieldSchema('Folder ID is required').max( + 128, + 'Folder ID is too long' +) + /** * A `workspace_files.id` value. The column is a free-form `text` primary key, so * ids come in two shapes: UUID v4 (legacy rows and the `insertFileMetadata` diff --git a/apps/sim/lib/api/contracts/tables.ts b/apps/sim/lib/api/contracts/tables.ts index b128660d855..671b240e27e 100644 --- a/apps/sim/lib/api/contracts/tables.ts +++ b/apps/sim/lib/api/contracts/tables.ts @@ -1,6 +1,10 @@ import { isRecordLike } from '@sim/utils/object' import { z } from 'zod' -import { requiredFieldSchema, workspaceIdSchema } from '@/lib/api/contracts/primitives' +import { + folderIdSchema, + requiredFieldSchema, + workspaceIdSchema, +} from '@/lib/api/contracts/primitives' import { type ContractJsonResponse, defineRouteContract } from '@/lib/api/contracts/types' import { ianaTimezoneSchema } from '@/lib/api/contracts/user' import type { @@ -165,6 +169,12 @@ export const createTableBodySchema = z.object({ ), }), workspaceId: workspaceIdSchema, + /** + * Folder to create the table in. Omitted or `null` creates it at the workspace + * root — both spellings are accepted so a client can pass the current folder + * through unconditionally. + */ + folderId: folderIdSchema.nullable().optional(), initialRowCount: z.number().int().min(0).max(100).optional(), }) @@ -182,21 +192,25 @@ export const tableLocksSchema = z.object({ }) satisfies z.ZodType /** - * PATCH /api/table/[tableId] body. Both fields are optional but at least one - * must be present. `name` is a `write`-level rename; `locks` is an - * admin-only governance change (a partial — only the toggled flags are sent). + * PATCH /api/table/[tableId] body. Every field is optional but at least one + * must be present. `name` is a `write`-level rename; `folderId` is a + * `write`-level move (explicit `null` moves the table to the workspace root, and + * omission leaves the placement untouched — the tri-state is deliberate here); + * `locks` is an admin-only governance change (a partial — only the toggled flags + * are sent). */ export const updateTableBodySchema = z .object({ workspaceId: workspaceIdSchema, name: tableNameSchema.optional(), + folderId: folderIdSchema.nullable().optional(), locks: tableLocksSchema.partial().optional(), }) .superRefine((body, ctx) => { - if (body.name === undefined && body.locks === undefined) { + if (body.name === undefined && body.locks === undefined && body.folderId === undefined) { ctx.addIssue({ code: z.ZodIssueCode.custom, - message: 'Provide a new name or lock changes', + message: 'Provide a new name, folder, or lock changes', path: ['name'], }) } @@ -492,6 +506,8 @@ export const importTableAsyncBodySchema = z.object({ workspaceId: workspaceIdSchema, fileKey: requiredFieldSchema('fileKey is required'), fileName: requiredFieldSchema('fileName is required'), + /** Folder to create the imported table in; omitted or `null` imports to the workspace root. */ + folderId: folderIdSchema.nullable().optional(), /** * Whether the source object is deleted once the import is terminal. Defaults to true (the upload * flow stores a single-use temp object); pass false when importing an existing workspace file @@ -786,6 +802,8 @@ export const csvFileSchema = z export const csvImportFormSchema = z.object({ file: csvFileSchema, workspaceId: z.string({ error: 'Workspace ID is required' }).min(1, 'Workspace ID is required'), + /** Folder to create the imported table in; omitted imports to the workspace root. */ + folderId: folderIdSchema.optional(), }) export const csvImportModeSchema = z.enum(['append', 'replace']) diff --git a/apps/sim/lib/copilot/tools/handlers/materialize-file.ts b/apps/sim/lib/copilot/tools/handlers/materialize-file.ts index f5e35019793..ab6d9004757 100644 --- a/apps/sim/lib/copilot/tools/handlers/materialize-file.ts +++ b/apps/sim/lib/copilot/tools/handlers/materialize-file.ts @@ -1,6 +1,6 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' -import { workflow, workspaceFileFolder, workspaceFiles } from '@sim/db/schema' +import { folder as folderTable, workflow, workspaceFiles } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { getErrorMessage, toError } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' @@ -389,12 +389,13 @@ async function executeExtract( ) .limit(1), db - .select({ id: workspaceFileFolder.id }) - .from(workspaceFileFolder) + .select({ id: folderTable.id }) + .from(folderTable) .where( and( - eq(workspaceFileFolder.parentId, existingFolderId), - isNull(workspaceFileFolder.deletedAt) + eq(folderTable.parentId, existingFolderId), + eq(folderTable.resourceType, 'file'), + isNull(folderTable.deletedAt) ) ) .limit(1), diff --git a/apps/sim/lib/folders/cascade.test.ts b/apps/sim/lib/folders/cascade.test.ts index 91c33b598dd..a99f683a42e 100644 --- a/apps/sim/lib/folders/cascade.test.ts +++ b/apps/sim/lib/folders/cascade.test.ts @@ -471,3 +471,49 @@ describe('FOLDER_RESOURCES', () => { } }) }) + +/** + * Knowledge bases and tables are the resource trees this cascade newly serves. The generic + * describes above already exercise both code paths; these pin the per-resource wiring the + * folder engine depends on, which is exactly what silently drifts. + */ +describe('knowledge_base and table folder resources', () => { + const knowledgeConfig = FOLDER_RESOURCES.knowledge_base + const tableConfig = FOLDER_RESOURCES.table + + it('routes both trees through their canonical archive and restore, not a bare row update', () => { + // A knowledge base owns documents, embeddings, and storage accounting; a table owns its + // own data partitions. Neither can be archived by stamping one column, so both must keep + // a hook rather than falling back to the generic UPDATE. + for (const config of [knowledgeConfig, tableConfig]) { + expect(config.archiveChildren).toBeTypeOf('function') + expect(config.restoreChildren).toBeTypeOf('function') + } + }) + + it('drives each tree off its own soft-delete column', () => { + expect(knowledgeConfig.deletedKey).toBe('deletedAt') + expect(knowledgeConfig.buildSoftDeleteSet(TIMESTAMP, NOW)).toEqual({ + deletedAt: TIMESTAMP, + updatedAt: NOW, + }) + expect(tableConfig.deletedKey).toBe('archivedAt') + expect(tableConfig.buildSoftDeleteSet(TIMESTAMP, NOW)).toEqual({ + archivedAt: TIMESTAMP, + updatedAt: NOW, + }) + }) + + it('guards a table folder delete on table locks and leaves knowledge folders unguarded', () => { + // Table locks are a governance feature that must survive a folder delete; knowledge + // bases have no equivalent, so a guard there would be dead weight. + expect(tableConfig.guardDelete).toBeTypeOf('function') + expect(knowledgeConfig.guardDelete).toBeUndefined() + }) + + it('keeps manual folder sort ordering workflow-only', () => { + // Only the workflow tree interleaves folders and rows in one user-ordered list. + expect(knowledgeConfig.sortOrderColumn).toBeUndefined() + expect(tableConfig.sortOrderColumn).toBeUndefined() + }) +}) diff --git a/apps/sim/lib/folders/lifecycle.ts b/apps/sim/lib/folders/lifecycle.ts index b26d9f2a00e..526e4eaff5a 100644 --- a/apps/sim/lib/folders/lifecycle.ts +++ b/apps/sim/lib/folders/lifecycle.ts @@ -480,7 +480,16 @@ export async function restoreFolder(params: RestoreFolderParams): Promise { + const [row] = await db + .select() + .from(folder) + .where( + and( + eq(folder.id, folderId), + eq(folder.workspaceId, workspaceId), + eq(folder.resourceType, resourceType), + isNull(folder.deletedAt) + ) + ) + .limit(1) + + return row ?? null +} + /** Shared by `GET /api/folders` and the sidebar prefetch so the query never drifts between them. */ export async function listFoldersForWorkspace( workspaceId: string, diff --git a/apps/sim/lib/knowledge/folders.test.ts b/apps/sim/lib/knowledge/folders.test.ts new file mode 100644 index 00000000000..a81ebba83bf --- /dev/null +++ b/apps/sim/lib/knowledge/folders.test.ts @@ -0,0 +1,202 @@ +/** + * @vitest-environment node + */ +import { dbChainMockFns, permissionsMock, permissionsMockFns, resetDbChainMock } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockApplyStorageUsageDeltasInTx, + mockEnsureUserStatsExists, + mockGetHighestPrioritySubscription, + mockFindActiveFolder, + mockMaybeNotifyStorageLimitForBillingContext, + mockResolveStorageBillingContext, +} = vi.hoisted(() => ({ + mockApplyStorageUsageDeltasInTx: vi.fn(), + mockEnsureUserStatsExists: vi.fn(), + mockGetHighestPrioritySubscription: vi.fn(), + mockFindActiveFolder: vi.fn(), + mockMaybeNotifyStorageLimitForBillingContext: vi.fn(), + mockResolveStorageBillingContext: vi.fn(), +})) + +vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock) +vi.mock('@/lib/folders/queries', () => ({ + findActiveFolder: mockFindActiveFolder, +})) +vi.mock('@/lib/billing/storage', () => ({ + applyStorageUsageDeltasInTx: mockApplyStorageUsageDeltasInTx, + maybeNotifyStorageLimitForBillingContext: mockMaybeNotifyStorageLimitForBillingContext, + resolveStorageBillingContext: mockResolveStorageBillingContext, +})) +vi.mock('@/lib/billing/core/subscription', () => ({ + getHighestPrioritySubscription: mockGetHighestPrioritySubscription, +})) +vi.mock('@/lib/billing/core/usage', () => ({ + ensureUserStatsExists: mockEnsureUserStatsExists, +})) + +import { + createKnowledgeBase, + KnowledgeBaseFolderError, + updateKnowledgeBase, +} from '@/lib/knowledge/service' + +const CREATE_INPUT = { + name: 'Base', + workspaceId: 'ws-1', + userId: 'u-1', + embeddingModel: 'text-embedding-3-small', + embeddingDimension: 1536 as const, + chunkingConfig: { maxSize: 1024, minSize: 100, overlap: 200 }, +} + +/** + * `knowledge_base.folder_id` has a plain FK to `folder.id`, which proves nothing about the + * workspace the folder belongs to or which resource tree it serves. These tests pin the + * application-level admission that stands in for the missing constraint. + */ +describe('createKnowledgeBase — folder assignment', () => { + beforeEach(() => { + vi.clearAllMocks() + dbChainMockFns.limit.mockReset() + resetDbChainMock() + dbChainMockFns.limit.mockResolvedValue([]) + permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('admin') + mockFindActiveFolder.mockResolvedValue({ id: 'f-1' }) + }) + + it('files the base under the requested folder', async () => { + const created = await createKnowledgeBase({ ...CREATE_INPUT, folderId: 'f-1' }, 'req-1') + + expect(mockFindActiveFolder).toHaveBeenCalledWith('f-1', 'ws-1', 'knowledge_base') + expect(dbChainMockFns.values).toHaveBeenCalledWith( + expect.objectContaining({ folderId: 'f-1', workspaceId: 'ws-1' }) + ) + expect(created.folderId).toBe('f-1') + }) + + it('creates at the workspace root when no folder is given, without a folder lookup', async () => { + const created = await createKnowledgeBase(CREATE_INPUT, 'req-1') + + expect(mockFindActiveFolder).not.toHaveBeenCalled() + expect(dbChainMockFns.values).toHaveBeenCalledWith(expect.objectContaining({ folderId: null })) + expect(created.folderId).toBeNull() + }) + + it('normalizes an explicit null folder to the workspace root', async () => { + const created = await createKnowledgeBase({ ...CREATE_INPUT, folderId: null }, 'req-1') + + expect(mockFindActiveFolder).not.toHaveBeenCalled() + expect(created.folderId).toBeNull() + }) + + it('rejects a folder that is not an active knowledge_base folder in the workspace', async () => { + mockFindActiveFolder.mockResolvedValue(null) + + await expect( + createKnowledgeBase({ ...CREATE_INPUT, folderId: 'f-other-workspace' }, 'req-1') + ).rejects.toBeInstanceOf(KnowledgeBaseFolderError) + expect(dbChainMockFns.insert).not.toHaveBeenCalled() + }) + + it('checks permission before touching the folder', async () => { + permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('read') + + await expect( + createKnowledgeBase({ ...CREATE_INPUT, folderId: 'f-1' }, 'req-1') + ).rejects.toMatchObject({ code: 'KNOWLEDGE_BASE_FORBIDDEN' }) + expect(mockFindActiveFolder).not.toHaveBeenCalled() + }) +}) + +describe('updateKnowledgeBase — folder moves', () => { + /** + * The mocked `@sim/db` cannot satisfy the post-transaction read-back select, so a + * successful update still rejects after the transaction body commits. + */ + const runIgnoringReadBack = (promise: Promise) => promise.catch(() => undefined) + + beforeEach(() => { + vi.clearAllMocks() + dbChainMockFns.limit.mockReset() + resetDbChainMock() + dbChainMockFns.limit.mockResolvedValue([ + { workspaceId: 'ws-1', userId: 'u-1', folderId: 'f-old' }, + ]) + permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('admin') + mockFindActiveFolder.mockResolvedValue({ id: 'f-1' }) + mockResolveStorageBillingContext.mockImplementation(async (workspaceId: string) => ({ + workspaceId, + billedAccountUserId: `${workspaceId}-owner`, + billingEntity: { type: 'user', id: `${workspaceId}-owner` }, + plan: 'team_25000', + customStorageLimitGB: null, + })) + mockApplyStorageUsageDeltasInTx.mockResolvedValue(100) + mockEnsureUserStatsExists.mockResolvedValue(undefined) + mockGetHighestPrioritySubscription.mockResolvedValue(null) + }) + + it('writes the new folder against the current workspace', async () => { + await runIgnoringReadBack(updateKnowledgeBase('kb-1', { folderId: 'f-new' }, 'req-1')) + + expect(mockFindActiveFolder).toHaveBeenCalledWith('f-new', 'ws-1', 'knowledge_base') + expect(dbChainMockFns.set).toHaveBeenCalledWith(expect.objectContaining({ folderId: 'f-new' })) + }) + + it('moves the base to the workspace root on an explicit null', async () => { + await runIgnoringReadBack(updateKnowledgeBase('kb-1', { folderId: null }, 'req-1')) + + expect(mockFindActiveFolder).not.toHaveBeenCalled() + expect(dbChainMockFns.set).toHaveBeenCalledWith(expect.objectContaining({ folderId: null })) + }) + + it('rejects a folder outside the knowledge base workspace', async () => { + mockFindActiveFolder.mockResolvedValue(null) + + await expect( + updateKnowledgeBase('kb-1', { folderId: 'f-foreign' }, 'req-1') + ).rejects.toBeInstanceOf(KnowledgeBaseFolderError) + expect(dbChainMockFns.set).not.toHaveBeenCalled() + }) + + it('validates a folder move that accompanies a workspace change against the destination', async () => { + await runIgnoringReadBack( + updateKnowledgeBase('kb-1', { workspaceId: 'ws-2', folderId: 'f-dest' }, 'req-1', { + actorUserId: 'u-1', + }) + ) + + expect(mockFindActiveFolder).toHaveBeenCalledWith('f-dest', 'ws-2', 'knowledge_base') + }) + + it('re-roots the base when its workspace changes and no folder is named', async () => { + await runIgnoringReadBack( + updateKnowledgeBase('kb-1', { workspaceId: 'ws-2' }, 'req-1', { actorUserId: 'u-1' }) + ) + + expect(dbChainMockFns.set).toHaveBeenCalledWith(expect.objectContaining({ folderId: null })) + }) + + it('leaves the folder alone when the workspace is unchanged', async () => { + await runIgnoringReadBack( + updateKnowledgeBase('kb-1', { workspaceId: 'ws-1' }, 'req-1', { actorUserId: 'u-1' }) + ) + + expect(dbChainMockFns.set).not.toHaveBeenCalledWith(expect.objectContaining({ folderId: null })) + }) + + it('leaves the folder alone on a plain rename', async () => { + dbChainMockFns.limit + .mockResolvedValueOnce([{ workspaceId: 'ws-1', userId: 'u-1', folderId: 'f-old' }]) // row lock + .mockResolvedValueOnce([]) // duplicate-name check: none + + await runIgnoringReadBack(updateKnowledgeBase('kb-1', { name: 'Renamed' }, 'req-1')) + + expect(mockFindActiveFolder).not.toHaveBeenCalled() + expect(dbChainMockFns.set).toHaveBeenCalledWith( + expect.not.objectContaining({ folderId: expect.anything() }) + ) + }) +}) diff --git a/apps/sim/lib/knowledge/service.ts b/apps/sim/lib/knowledge/service.ts index 469def4fbb1..d2e18f12e4f 100644 --- a/apps/sim/lib/knowledge/service.ts +++ b/apps/sim/lib/knowledge/service.ts @@ -21,6 +21,7 @@ import { type StorageBillingContext, } from '@/lib/billing/storage' import { generateRestoreName } from '@/lib/core/utils/restore-name' +import { findActiveFolder } from '@/lib/folders/queries' import type { ChunkingConfig, CreateKnowledgeBaseData, @@ -41,6 +42,29 @@ export class KnowledgeBasePermissionError extends Error { readonly code = 'KNOWLEDGE_BASE_FORBIDDEN' as const } +/** Raised when a caller files a knowledge base under a folder it may not use. */ +export class KnowledgeBaseFolderError extends Error { + readonly code = 'KNOWLEDGE_BASE_FOLDER_INVALID' as const + constructor() { + super('Folder not found in this workspace') + } +} + +/** + * Verifies `folderId` is an active `knowledge_base` folder in `workspaceId`. A `null` target + * (the workspace root) needs no check. + */ +async function assertKnowledgeBaseFolder( + folderId: string | null | undefined, + workspaceId: string | null +): Promise { + if (!folderId) return + if (!workspaceId) throw new KnowledgeBaseFolderError() + if (!(await findActiveFolder(folderId, workspaceId, 'knowledge_base'))) { + throw new KnowledgeBaseFolderError() + } +} + export type KnowledgeBaseScope = 'active' | 'archived' | 'all' type KnowledgeBaseStorageMove = @@ -94,6 +118,7 @@ export async function getKnowledgeBases( updatedAt: knowledgeBase.updatedAt, deletedAt: knowledgeBase.deletedAt, workspaceId: knowledgeBase.workspaceId, + folderId: knowledgeBase.folderId, docCount: count(document.id), }) .from(knowledgeBase) @@ -195,11 +220,16 @@ export async function createKnowledgeBase( ) } + await assertKnowledgeBaseFolder(data.folderId, data.workspaceId) + + const folderId = data.folderId ?? null + const newKnowledgeBase = { id: kbId, name: data.name, description: data.description ?? null, workspaceId: data.workspaceId, + folderId, userId: data.userId, tokenCount: 0, embeddingModel: data.embeddingModel, @@ -250,6 +280,7 @@ export async function createKnowledgeBase( updatedAt: now, deletedAt: null, workspaceId: data.workspaceId, + folderId, docCount: 0, connectorTypes: [], } @@ -264,6 +295,7 @@ export async function updateKnowledgeBase( name?: string description?: string workspaceId?: string | null + folderId?: string | null chunkingConfig?: { maxSize: number minSize: number @@ -274,25 +306,14 @@ export async function updateKnowledgeBase( options?: { actorUserId?: string } ): Promise { const now = new Date() - const updateData: { - updatedAt: Date - name?: string - description?: string | null - workspaceId?: string | null - chunkingConfig?: { - maxSize: number - minSize: number - overlap: number - } - embeddingModel?: string - embeddingDimension?: number - } = { + const updateData: Partial = { updatedAt: now, } 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.chunkingConfig !== undefined) { updateData.chunkingConfig = updates.chunkingConfig } @@ -303,6 +324,30 @@ export async function updateKnowledgeBase( ) } + /** + * Folder admission is resolved against the workspace the knowledge base will end up in, + * before the transaction opens — same posture as the permission and storage lookups below, + * which deliberately keep external reads off a pooled transaction connection. + * + * A workspace change without an explicit folder needs no lookup here: the storage block + * below already reads the current row, and re-roots from there. + */ + if (updates.folderId !== undefined) { + let effectiveWorkspaceId = updates.workspaceId + if (effectiveWorkspaceId === undefined) { + const [snapshot] = await db + .select({ workspaceId: knowledgeBase.workspaceId }) + .from(knowledgeBase) + .where(and(eq(knowledgeBase.id, knowledgeBaseId), isNull(knowledgeBase.deletedAt))) + .limit(1) + if (!snapshot) { + throw new Error(`Knowledge base ${knowledgeBaseId} not found`) + } + effectiveWorkspaceId = snapshot.workspaceId + } + await assertKnowledgeBaseFolder(updates.folderId, effectiveWorkspaceId) + } + /** * Resolve transfer admission before opening the transaction. The locked KB * row below revalidates this source snapshot; a concurrent move is an error @@ -311,7 +356,11 @@ export async function updateKnowledgeBase( let storageMove: KnowledgeBaseStorageMove | undefined if (updates.workspaceId !== undefined) { const [kbSnapshot] = await db - .select({ workspaceId: knowledgeBase.workspaceId, userId: knowledgeBase.userId }) + .select({ + workspaceId: knowledgeBase.workspaceId, + userId: knowledgeBase.userId, + folderId: knowledgeBase.folderId, + }) .from(knowledgeBase) .where(and(eq(knowledgeBase.id, knowledgeBaseId), isNull(knowledgeBase.deletedAt))) .limit(1) @@ -320,6 +369,20 @@ export async function updateKnowledgeBase( } const sourceWorkspaceId = kbSnapshot.workspaceId ?? null const destinationWorkspaceId = updates.workspaceId ?? null + + /** + * Folders never cross workspaces, so a workspace move would leave the row pointing at a + * folder the destination cannot render — an active knowledge base nobody can reach. + * Land it at the destination root unless the caller named a folder itself. + */ + if ( + updates.folderId === undefined && + kbSnapshot.folderId && + destinationWorkspaceId !== sourceWorkspaceId + ) { + updateData.folderId = null + } + if ( sourceWorkspaceId && destinationWorkspaceId && @@ -585,6 +648,7 @@ export async function updateKnowledgeBase( updatedAt: knowledgeBase.updatedAt, deletedAt: knowledgeBase.deletedAt, workspaceId: knowledgeBase.workspaceId, + folderId: knowledgeBase.folderId, docCount: count(document.id), }) .from(knowledgeBase) @@ -635,6 +699,7 @@ export async function getKnowledgeBaseById( updatedAt: knowledgeBase.updatedAt, deletedAt: knowledgeBase.deletedAt, workspaceId: knowledgeBase.workspaceId, + folderId: knowledgeBase.folderId, docCount: count(document.id), }) .from(knowledgeBase) @@ -736,6 +801,7 @@ export async function restoreKnowledgeBase( name: knowledgeBase.name, deletedAt: knowledgeBase.deletedAt, workspaceId: knowledgeBase.workspaceId, + folderId: knowledgeBase.folderId, }) .from(knowledgeBase) .where(eq(knowledgeBase.id, knowledgeBaseId)) @@ -757,6 +823,18 @@ export async function restoreKnowledgeBase( } } + /** + * Restoring a knowledge base whose folder is still archived would file it under a folder + * the Knowledge page never renders, leaving an active row nobody can reach. Re-root it + * instead — the same treatment `restoreFolder` gives a folder with an archived parent. + */ + const restoredFolderId = + kb.folderId && kb.workspaceId + ? (await findActiveFolder(kb.folderId, kb.workspaceId, 'knowledge_base')) + ? kb.folderId + : null + : null + /** * A concurrent create/rename can commit the same active name after `generateRestoreName`'s check * (MVCC) and before this transaction commits. Retries pick a new random suffix; 23505 is still @@ -791,7 +869,12 @@ export async function restoreKnowledgeBase( await tx .update(knowledgeBase) - .set({ deletedAt: null, updatedAt: now, name: attemptedRestoreName }) + .set({ + deletedAt: null, + updatedAt: now, + name: attemptedRestoreName, + folderId: restoredFolderId, + }) .where(eq(knowledgeBase.id, knowledgeBaseId)) await tx diff --git a/apps/sim/lib/knowledge/types.ts b/apps/sim/lib/knowledge/types.ts index 3a729f0981c..3444945ef41 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 + /** Folder in the workspace's `knowledge_base` folder tree; `null` at the root. */ + folderId: string | null docCount: number connectorTypes: string[] } @@ -34,6 +36,7 @@ export interface CreateKnowledgeBaseData { name: string description?: string workspaceId: string + folderId?: string | null embeddingModel: string embeddingDimension: 1536 chunkingConfig: ChunkingConfig @@ -114,6 +117,8 @@ export interface KnowledgeBaseData { updatedAt: string deletedAt: string | null workspaceId: string | null + /** Folder in the workspace's `knowledge_base` folder tree; `null` at the root. */ + folderId: string | null docCount?: number connectorTypes?: string[] } diff --git a/apps/sim/lib/pinned-items/resources.ts b/apps/sim/lib/pinned-items/resources.ts index 63340211ca9..ce1d8b95f1e 100644 --- a/apps/sim/lib/pinned-items/resources.ts +++ b/apps/sim/lib/pinned-items/resources.ts @@ -1,9 +1,9 @@ import { db, + folder as folderTable, knowledgeBase, userTableDefinitions, workflow, - workspaceFileFolder, workspaceFiles, } from '@sim/db' import { and, eq, inArray, isNull, type SQL } from 'drizzle-orm' @@ -55,21 +55,16 @@ const PINNED_RESOURCES: Record = { deletedColumn: userTableDefinitions.archivedAt, }, /** - * File folders are the only pinnable folders today, and they are still read and - * written against `workspace_file_folders` — the generic `folder` table currently - * backs workflow folders only, and its `resource_type = 'file'` rows are a one-time - * backfill that new file folders never reach. Resolving pins against `folder` would - * therefore drop every folder created after migration 0272. - * - * That backfill preserved folder ids, so when the file-folder cutover lands this - * entry repoints at `folder` (scoped to `resourceType = 'file'`) without invalidating - * a single existing pin. + * One entry covers every folder tree. Deliberately unscoped by `resourceType`: file, + * knowledge-base, and table folders are all pinnable and all live in `folder`, and a + * folder id addresses exactly one row regardless of which tree it belongs to. The + * workspace filter below still scopes the check. */ folder: { - table: workspaceFileFolder, - idColumn: workspaceFileFolder.id, - workspaceColumn: workspaceFileFolder.workspaceId, - deletedColumn: workspaceFileFolder.deletedAt, + table: folderTable, + idColumn: folderTable.id, + workspaceColumn: folderTable.workspaceId, + deletedColumn: folderTable.deletedAt, }, } diff --git a/apps/sim/lib/table/service.ts b/apps/sim/lib/table/service.ts index 22bdca95170..defac61d270 100644 --- a/apps/sim/lib/table/service.ts +++ b/apps/sim/lib/table/service.ts @@ -160,6 +160,7 @@ export async function getTableById( metadata: userTableDefinitions.metadata, maxRows: userTableDefinitions.maxRows, workspaceId: userTableDefinitions.workspaceId, + folderId: userTableDefinitions.folderId, createdBy: userTableDefinitions.createdBy, archivedAt: userTableDefinitions.archivedAt, createdAt: userTableDefinitions.createdAt, @@ -189,6 +190,7 @@ export async function getTableById( rowCount: Math.max(0, table.rowCount - pendingDeleteRemaining), maxRows: table.maxRows, workspaceId: table.workspaceId, + folderId: table.folderId, createdBy: table.createdBy, locks: readLocks(table), archivedAt: table.archivedAt, @@ -218,6 +220,7 @@ export async function listTables( metadata: userTableDefinitions.metadata, maxRows: userTableDefinitions.maxRows, workspaceId: userTableDefinitions.workspaceId, + folderId: userTableDefinitions.folderId, createdBy: userTableDefinitions.createdBy, archivedAt: userTableDefinitions.archivedAt, createdAt: userTableDefinitions.createdAt, @@ -255,6 +258,7 @@ export async function listTables( rowCount: Math.max(0, t.rowCount - pendingDeleteRemaining), maxRows: t.maxRows, workspaceId: t.workspaceId, + folderId: t.folderId, createdBy: t.createdBy, locks: readLocks(t), archivedAt: t.archivedAt, @@ -306,6 +310,7 @@ export async function createTable( description: data.description ?? null, schema, workspaceId: data.workspaceId, + folderId: data.folderId ?? null, createdBy: data.userId, maxRows, archivedAt: null, @@ -427,6 +432,7 @@ export async function createTable( rowCount: data.initialRowCount ?? 0, maxRows: newTable.maxRows, workspaceId: newTable.workspaceId, + folderId: newTable.folderId, createdBy: newTable.createdBy, locks: UNLOCKED_TABLE_LOCKS, archivedAt: newTable.archivedAt, @@ -614,6 +620,62 @@ export async function renameTable( } } +/** + * Moves a table into `folderId`, or to the workspace root when it is `null`. + * + * The caller is responsible for verifying that the folder exists, belongs to the same + * workspace, is active, and carries `resourceType: 'table'` — see `findActiveFolder` in + * `@/lib/folders/queries`. Table names are unique workspace-wide rather than per folder, so + * a move can never collide on name. + * + * Deliberately asserts no mutation lock: the four `user_table_definitions` lock flags govern + * schema and row writes, and folder placement is neither. + */ +export async function moveTableToFolder( + tableId: string, + folderId: string | null, + requestId: string, + actingUserId?: string +): Promise { + const updates: Partial = { + folderId, + updatedAt: new Date(), + } + + const result = await db + .update(userTableDefinitions) + .set(updates) + .where(eq(userTableDefinitions.id, tableId)) + .returning({ + name: userTableDefinitions.name, + createdBy: userTableDefinitions.createdBy, + workspaceId: userTableDefinitions.workspaceId, + }) + + if (result.length === 0) { + throw new Error(`Table ${tableId} not found`) + } + + const { name, createdBy, workspaceId } = result[0] + const actorId = actingUserId ?? createdBy + if (actorId) { + recordAudit({ + workspaceId: workspaceId ?? null, + actorId, + action: AuditAction.TABLE_UPDATED, + resourceType: AuditResourceType.TABLE, + resourceId: tableId, + resourceName: name, + description: folderId + ? `Moved table "${name}" into a folder` + : `Moved table "${name}" to the workspace root`, + metadata: { op: 'move', folderId }, + }) + } + + logger.info(`[${requestId}] Moved table ${tableId} to folder ${folderId ?? 'root'}`) +} + /** * Applies a partial lock change to a table. The caller authorizes (admin-only); * this function does the write. diff --git a/apps/sim/lib/table/types.ts b/apps/sim/lib/table/types.ts index 95f6e29eafc..b47f2b04608 100644 --- a/apps/sim/lib/table/types.ts +++ b/apps/sim/lib/table/types.ts @@ -394,6 +394,8 @@ export interface TableDefinition { rowCount: number maxRows: number workspaceId: string + /** Folder the table lives in, or `null` at the workspace root. */ + folderId?: string | null createdBy: string /** Per-table mutation locks; absent-as-all-false is normalized on read. */ locks: TableLocks @@ -548,6 +550,8 @@ export interface CreateTableData { schema: TableSchema workspaceId: string userId: string + /** Folder to create the table in. Omitted or `null` creates it at 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 12daaef648c..03f58a0d3a3 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,14 +1,24 @@ import { db } from '@sim/db' -import { workspaceFileFolder, workspaceFiles } from '@sim/db/schema' +import { folder as folderTable, workspaceFiles } from '@sim/db/schema' import { createLogger } from '@sim/logger' 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 { deduplicateFolderName } from '@/lib/folders/naming' import { collectDescendantFolderIds } from '@/lib/folders/subtree' import { getWorkspaceWithOwner } from '@/lib/workspaces/permissions/utils' const logger = createLogger('WorkspaceFileFolders') +/** + * File folders live in the generic `folder` table alongside workflow, knowledge-base, and + * table folders. Every read and write here — id-keyed lookups included — carries this + * predicate: folder ids are UUIDs and cannot collide, but nothing stops a caller from + * handing a workflow folder's id to a file-folder endpoint. + */ +const FILE_FOLDER_RESOURCE_TYPE = 'file' as const +const isFileFolder = eq(folderTable.resourceType, FILE_FOLDER_RESOURCE_TYPE) + /** * Bounds the workspace-file-folder advisory-lock wait so a stuck holder fails * fast (SQLSTATE 55P03) rather than hanging, even if the deployment lacks a @@ -102,9 +112,7 @@ function normalizeParentId(parentId?: string | null): string | null { function folderParentCondition(parentId?: string | null) { const normalized = normalizeParentId(parentId) - return normalized - ? eq(workspaceFileFolder.parentId, normalized) - : isNull(workspaceFileFolder.parentId) + return normalized ? eq(folderTable.parentId, normalized) : isNull(folderTable.parentId) } function fileFolderCondition(folderId?: string | null) { @@ -178,17 +186,15 @@ async function getRawWorkspaceFileFolder( const { includeDeleted = false } = options ?? {} const [folder] = await db .select() - .from(workspaceFileFolder) + .from(folderTable) .where( includeDeleted - ? and( - eq(workspaceFileFolder.id, folderId), - eq(workspaceFileFolder.workspaceId, workspaceId) - ) + ? and(eq(folderTable.id, folderId), eq(folderTable.workspaceId, workspaceId), isFileFolder) : and( - eq(workspaceFileFolder.id, folderId), - eq(workspaceFileFolder.workspaceId, workspaceId), - isNull(workspaceFileFolder.deletedAt) + eq(folderTable.id, folderId), + eq(folderTable.workspaceId, workspaceId), + isFileFolder, + isNull(folderTable.deletedAt) ) ) .limit(1) @@ -203,13 +209,14 @@ async function findRawWorkspaceFileFolderByName( ): Promise { const [folder] = await db .select() - .from(workspaceFileFolder) + .from(folderTable) .where( and( - eq(workspaceFileFolder.workspaceId, workspaceId), - eq(workspaceFileFolder.name, name), + eq(folderTable.workspaceId, workspaceId), + isFileFolder, + eq(folderTable.name, name), folderParentCondition(parentId), - isNull(workspaceFileFolder.deletedAt) + isNull(folderTable.deletedAt) ) ) .limit(1) @@ -284,21 +291,23 @@ export async function listWorkspaceFileFolders( const { scope = 'active' } = options ?? {} const rows = await db .select() - .from(workspaceFileFolder) + .from(folderTable) .where( scope === 'all' - ? eq(workspaceFileFolder.workspaceId, workspaceId) + ? and(eq(folderTable.workspaceId, workspaceId), isFileFolder) : scope === 'archived' ? and( - eq(workspaceFileFolder.workspaceId, workspaceId), - sql`${workspaceFileFolder.deletedAt} IS NOT NULL` + eq(folderTable.workspaceId, workspaceId), + isFileFolder, + sql`${folderTable.deletedAt} IS NOT NULL` ) : and( - eq(workspaceFileFolder.workspaceId, workspaceId), - isNull(workspaceFileFolder.deletedAt) + eq(folderTable.workspaceId, workspaceId), + isFileFolder, + isNull(folderTable.deletedAt) ) ) - .orderBy(asc(workspaceFileFolder.sortOrder), asc(workspaceFileFolder.createdAt)) + .orderBy(asc(folderTable.sortOrder), asc(folderTable.createdAt)) const paths = buildWorkspaceFileFolderPathMap(rows) return rows.map((row) => mapFolder(row, paths)) @@ -317,14 +326,11 @@ export async function getWorkspaceFileFolder( // per-ancestor SELECTs inside buildWorkspaceFileFolderPath. const allFolders = await db .select() - .from(workspaceFileFolder) + .from(folderTable) .where( includeDeleted - ? eq(workspaceFileFolder.workspaceId, workspaceId) - : and( - eq(workspaceFileFolder.workspaceId, workspaceId), - isNull(workspaceFileFolder.deletedAt) - ) + ? and(eq(folderTable.workspaceId, workspaceId), isFileFolder) + : and(eq(folderTable.workspaceId, workspaceId), isFileFolder, isNull(folderTable.deletedAt)) ) const paths = buildWorkspaceFileFolderPathMap(allFolders) @@ -361,13 +367,14 @@ export async function createWorkspaceFileFolder(params: { const parentId = normalizeParentId(params.parentId) if (parentId) { const [target] = await tx - .select({ id: workspaceFileFolder.id }) - .from(workspaceFileFolder) + .select({ id: folderTable.id }) + .from(folderTable) .where( and( - eq(workspaceFileFolder.id, parentId), - eq(workspaceFileFolder.workspaceId, params.workspaceId), - isNull(workspaceFileFolder.deletedAt) + eq(folderTable.id, parentId), + eq(folderTable.workspaceId, params.workspaceId), + isFileFolder, + isNull(folderTable.deletedAt) ) ) .limit(1) @@ -378,14 +385,15 @@ export async function createWorkspaceFileFolder(params: { } const existingFolders = await tx - .select({ id: workspaceFileFolder.id }) - .from(workspaceFileFolder) + .select({ id: folderTable.id }) + .from(folderTable) .where( and( - eq(workspaceFileFolder.workspaceId, params.workspaceId), - eq(workspaceFileFolder.name, name), + eq(folderTable.workspaceId, params.workspaceId), + isFileFolder, + eq(folderTable.name, name), folderParentCondition(parentId), - isNull(workspaceFileFolder.deletedAt) + isNull(folderTable.deletedAt) ) ) .limit(1) @@ -395,22 +403,24 @@ export async function createWorkspaceFileFolder(params: { } const [sortOrderResult] = await tx - .select({ minSortOrder: min(workspaceFileFolder.sortOrder) }) - .from(workspaceFileFolder) + .select({ minSortOrder: min(folderTable.sortOrder) }) + .from(folderTable) .where( and( - eq(workspaceFileFolder.workspaceId, params.workspaceId), + eq(folderTable.workspaceId, params.workspaceId), + isFileFolder, folderParentCondition(parentId), - isNull(workspaceFileFolder.deletedAt) + isNull(folderTable.deletedAt) ) ) const id = generateId() try { const [inserted] = await tx - .insert(workspaceFileFolder) + .insert(folderTable) .values({ id, + resourceType: FILE_FOLDER_RESOURCE_TYPE, name, userId: params.userId, workspaceId: params.workspaceId, @@ -449,11 +459,12 @@ export async function ensureWorkspaceFileFolderPath(params: { // so we can resolve existing segments without a per-segment SELECT. const existingFolders = await db .select() - .from(workspaceFileFolder) + .from(folderTable) .where( and( - eq(workspaceFileFolder.workspaceId, params.workspaceId), - isNull(workspaceFileFolder.deletedAt) + eq(folderTable.workspaceId, params.workspaceId), + isFileFolder, + isNull(folderTable.deletedAt) ) ) @@ -536,19 +547,20 @@ export async function updateWorkspaceFileFolder(params: { const [existing] = await tx .select() - .from(workspaceFileFolder) + .from(folderTable) .where( and( - eq(workspaceFileFolder.id, params.folderId), - eq(workspaceFileFolder.workspaceId, params.workspaceId), - isNull(workspaceFileFolder.deletedAt) + eq(folderTable.id, params.folderId), + eq(folderTable.workspaceId, params.workspaceId), + isFileFolder, + isNull(folderTable.deletedAt) ) ) .limit(1) if (!existing) throw new Error('Folder not found') - const updates: Partial = { updatedAt: new Date() } + const updates: Partial = { updatedAt: new Date() } const finalName = params.name !== undefined ? normalizeWorkspaceFileItemName(params.name, 'Folder') @@ -560,13 +572,14 @@ export async function updateWorkspaceFileFolder(params: { if (finalParentId) { const [target] = await tx - .select({ id: workspaceFileFolder.id }) - .from(workspaceFileFolder) + .select({ id: folderTable.id }) + .from(folderTable) .where( and( - eq(workspaceFileFolder.id, finalParentId), - eq(workspaceFileFolder.workspaceId, params.workspaceId), - isNull(workspaceFileFolder.deletedAt) + eq(folderTable.id, finalParentId), + eq(folderTable.workspaceId, params.workspaceId), + isFileFolder, + isNull(folderTable.deletedAt) ) ) .limit(1) @@ -578,12 +591,13 @@ export async function updateWorkspaceFileFolder(params: { if (params.parentId !== undefined) { const activeFolders = await tx - .select({ id: workspaceFileFolder.id, parentId: workspaceFileFolder.parentId }) - .from(workspaceFileFolder) + .select({ id: folderTable.id, parentId: folderTable.parentId }) + .from(folderTable) .where( and( - eq(workspaceFileFolder.workspaceId, params.workspaceId), - isNull(workspaceFileFolder.deletedAt) + eq(folderTable.workspaceId, params.workspaceId), + isFileFolder, + isNull(folderTable.deletedAt) ) ) @@ -595,14 +609,15 @@ export async function updateWorkspaceFileFolder(params: { if (finalName !== existing.name || finalParentId !== existing.parentId) { const conflictingFolders = await tx - .select({ id: workspaceFileFolder.id }) - .from(workspaceFileFolder) + .select({ id: folderTable.id }) + .from(folderTable) .where( and( - eq(workspaceFileFolder.workspaceId, params.workspaceId), - eq(workspaceFileFolder.name, finalName), + eq(folderTable.workspaceId, params.workspaceId), + eq(folderTable.name, finalName), folderParentCondition(finalParentId), - isNull(workspaceFileFolder.deletedAt) + isFileFolder, + isNull(folderTable.deletedAt) ) ) .limit(2) @@ -626,13 +641,14 @@ export async function updateWorkspaceFileFolder(params: { try { const [updatedFolder] = await tx - .update(workspaceFileFolder) + .update(folderTable) .set(updates) .where( and( - eq(workspaceFileFolder.id, params.folderId), - eq(workspaceFileFolder.workspaceId, params.workspaceId), - isNull(workspaceFileFolder.deletedAt) + eq(folderTable.id, params.folderId), + eq(folderTable.workspaceId, params.workspaceId), + isFileFolder, + isNull(folderTable.deletedAt) ) ) .returning() @@ -688,13 +704,14 @@ export async function moveWorkspaceFileItems(params: { if (targetFolderId) { const [target] = await tx - .select({ id: workspaceFileFolder.id }) - .from(workspaceFileFolder) + .select({ id: folderTable.id }) + .from(folderTable) .where( and( - eq(workspaceFileFolder.id, targetFolderId), - eq(workspaceFileFolder.workspaceId, params.workspaceId), - isNull(workspaceFileFolder.deletedAt) + eq(folderTable.id, targetFolderId), + eq(folderTable.workspaceId, params.workspaceId), + isFileFolder, + isNull(folderTable.deletedAt) ) ) .limit(1) @@ -710,12 +727,13 @@ export async function moveWorkspaceFileItems(params: { if (folderIds.length > 0) { const activeFolders = await tx - .select({ id: workspaceFileFolder.id, parentId: workspaceFileFolder.parentId }) - .from(workspaceFileFolder) + .select({ id: folderTable.id, parentId: folderTable.parentId }) + .from(folderTable) .where( and( - eq(workspaceFileFolder.workspaceId, params.workspaceId), - isNull(workspaceFileFolder.deletedAt) + eq(folderTable.workspaceId, params.workspaceId), + isFileFolder, + isNull(folderTable.deletedAt) ) ) @@ -745,13 +763,14 @@ export async function moveWorkspaceFileItems(params: { const movingFolders = folderIds.length > 0 ? await tx - .select({ id: workspaceFileFolder.id, name: workspaceFileFolder.name }) - .from(workspaceFileFolder) + .select({ id: folderTable.id, name: folderTable.name }) + .from(folderTable) .where( and( - inArray(workspaceFileFolder.id, folderIds), - eq(workspaceFileFolder.workspaceId, params.workspaceId), - isNull(workspaceFileFolder.deletedAt) + inArray(folderTable.id, folderIds), + eq(folderTable.workspaceId, params.workspaceId), + isFileFolder, + isNull(folderTable.deletedAt) ) ) : [] @@ -790,14 +809,15 @@ export async function moveWorkspaceFileItems(params: { for (const folder of movingFolders) { movingFolderNameCounts.set(folder.name, (movingFolderNameCounts.get(folder.name) ?? 0) + 1) const conflictingFolders = await tx - .select({ id: workspaceFileFolder.id }) - .from(workspaceFileFolder) + .select({ id: folderTable.id }) + .from(folderTable) .where( and( - eq(workspaceFileFolder.workspaceId, params.workspaceId), - eq(workspaceFileFolder.name, folder.name), + eq(folderTable.workspaceId, params.workspaceId), + eq(folderTable.name, folder.name), folderParentCondition(targetFolderId), - isNull(workspaceFileFolder.deletedAt) + isFileFolder, + isNull(folderTable.deletedAt) ) ) .limit(2) @@ -832,16 +852,17 @@ export async function moveWorkspaceFileItems(params: { const movedFolders = folderIds.length > 0 ? await tx - .update(workspaceFileFolder) + .update(folderTable) .set({ parentId: targetFolderId, updatedAt: new Date() }) .where( and( - inArray(workspaceFileFolder.id, folderIds), - eq(workspaceFileFolder.workspaceId, params.workspaceId), - isNull(workspaceFileFolder.deletedAt) + inArray(folderTable.id, folderIds), + eq(folderTable.workspaceId, params.workspaceId), + isFileFolder, + isNull(folderTable.deletedAt) ) ) - .returning({ id: workspaceFileFolder.id }) + .returning({ id: folderTable.id }) : [] return { movedFiles: movedFiles.length, movedFolders: movedFolders.length } @@ -858,13 +879,14 @@ export async function archiveWorkspaceFileFolderRecursive( await acquireWorkspaceFileFolderMutationLock(tx, workspaceId) const [folder] = await tx - .select({ id: workspaceFileFolder.id }) - .from(workspaceFileFolder) + .select({ id: folderTable.id }) + .from(folderTable) .where( and( - eq(workspaceFileFolder.id, folderId), - eq(workspaceFileFolder.workspaceId, workspaceId), - isNull(workspaceFileFolder.deletedAt) + eq(folderTable.id, folderId), + eq(folderTable.workspaceId, workspaceId), + isFileFolder, + isNull(folderTable.deletedAt) ) ) .limit(1) @@ -872,10 +894,10 @@ export async function archiveWorkspaceFileFolderRecursive( if (!folder) throw new Error('Folder not found') const activeFolders = await tx - .select({ id: workspaceFileFolder.id, parentId: workspaceFileFolder.parentId }) - .from(workspaceFileFolder) + .select({ id: folderTable.id, parentId: folderTable.parentId }) + .from(folderTable) .where( - and(eq(workspaceFileFolder.workspaceId, workspaceId), isNull(workspaceFileFolder.deletedAt)) + and(eq(folderTable.workspaceId, workspaceId), isFileFolder, isNull(folderTable.deletedAt)) ) const folderIds = [folderId, ...collectDescendantFolderIds(activeFolders, folderId)] @@ -893,16 +915,17 @@ export async function archiveWorkspaceFileFolderRecursive( .returning({ id: workspaceFiles.id }) const archivedFolders = await tx - .update(workspaceFileFolder) + .update(folderTable) .set({ deletedAt: now, updatedAt: now }) .where( and( - inArray(workspaceFileFolder.id, folderIds), - eq(workspaceFileFolder.workspaceId, workspaceId), - isNull(workspaceFileFolder.deletedAt) + inArray(folderTable.id, folderIds), + eq(folderTable.workspaceId, workspaceId), + isFileFolder, + isNull(folderTable.deletedAt) ) ) - .returning({ id: workspaceFileFolder.id }) + .returning({ id: folderTable.id }) logger.info('Archived workspace file folder recursively', { workspaceId, @@ -929,9 +952,9 @@ export async function restoreWorkspaceFileFolder( const raw = await tx .select() - .from(workspaceFileFolder) + .from(folderTable) .where( - and(eq(workspaceFileFolder.id, folderId), eq(workspaceFileFolder.workspaceId, workspaceId)) + and(eq(folderTable.id, folderId), eq(folderTable.workspaceId, workspaceId), isFileFolder) ) .limit(1) .then((rows) => rows[0] ?? null) @@ -946,12 +969,13 @@ export async function restoreWorkspaceFileFolder( let resolvedParentId = raw.parentId if (resolvedParentId) { const parent = await tx - .select({ deletedAt: workspaceFileFolder.deletedAt }) - .from(workspaceFileFolder) + .select({ deletedAt: folderTable.deletedAt }) + .from(folderTable) .where( and( - eq(workspaceFileFolder.id, resolvedParentId), - eq(workspaceFileFolder.workspaceId, workspaceId) + eq(folderTable.id, resolvedParentId), + eq(folderTable.workspaceId, workspaceId), + isFileFolder ) ) .limit(1) @@ -959,6 +983,29 @@ export async function restoreWorkspaceFileFolder( if (!parent || parent.deletedAt) resolvedParentId = null } + // Clearing `deletedAt` below brings this row back under the partial unique index on + // active (workspaceId, resourceType, parentId, name). The caller cannot rename an + // archived folder, so a sibling that took the name while this folder was gone would + // otherwise make it permanently unrestorable — dedupe instead of erroring. Deduped + // against the RESOLVED parent, which re-roots when the original parent is still + // archived. Only the restore root can collide: descendants come back alongside the + // exact siblings they were archived with. + const restoredName = await deduplicateFolderName( + tx, + workspaceId, + resolvedParentId, + raw.name, + FILE_FOLDER_RESOURCE_TYPE + ) + if (restoredName !== raw.name) { + logger.info('Renamed file folder on restore to avoid a sibling name conflict', { + workspaceId, + folderId, + from: raw.name, + to: restoredName, + }) + } + const stats: WorkspaceFileArchiveResult = { folders: 0, files: 0 } const seen = new Set() const restoreFolderSubtree = async (currentFolderId: string): Promise => { @@ -980,28 +1027,30 @@ export async function restoreWorkspaceFileFolder( stats.files += restoredFiles.length const archivedChildren = await tx - .select({ id: workspaceFileFolder.id }) - .from(workspaceFileFolder) + .select({ id: folderTable.id }) + .from(folderTable) .where( and( - eq(workspaceFileFolder.parentId, currentFolderId), - eq(workspaceFileFolder.workspaceId, workspaceId), - eq(workspaceFileFolder.deletedAt, folderDeletedAt) + eq(folderTable.parentId, currentFolderId), + eq(folderTable.workspaceId, workspaceId), + isFileFolder, + eq(folderTable.deletedAt, folderDeletedAt) ) ) for (const child of archivedChildren) { const [restoredChild] = await tx - .update(workspaceFileFolder) + .update(folderTable) .set({ deletedAt: null, updatedAt: new Date() }) .where( and( - eq(workspaceFileFolder.id, child.id), - eq(workspaceFileFolder.workspaceId, workspaceId), - eq(workspaceFileFolder.deletedAt, folderDeletedAt) + eq(folderTable.id, child.id), + eq(folderTable.workspaceId, workspaceId), + isFileFolder, + eq(folderTable.deletedAt, folderDeletedAt) ) ) - .returning({ id: workspaceFileFolder.id }) + .returning({ id: folderTable.id }) if (!restoredChild) continue stats.folders += 1 @@ -1010,10 +1059,15 @@ export async function restoreWorkspaceFileFolder( } const [row] = await tx - .update(workspaceFileFolder) - .set({ deletedAt: null, parentId: resolvedParentId, updatedAt: new Date() }) + .update(folderTable) + .set({ + deletedAt: null, + parentId: resolvedParentId, + name: restoredName, + updatedAt: new Date(), + }) .where( - and(eq(workspaceFileFolder.id, folderId), eq(workspaceFileFolder.workspaceId, workspaceId)) + and(eq(folderTable.id, folderId), eq(folderTable.workspaceId, workspaceId), isFileFolder) ) .returning() @@ -1027,9 +1081,9 @@ export async function restoreWorkspaceFileFolder( const allFolders = await db .select() - .from(workspaceFileFolder) + .from(folderTable) .where( - and(eq(workspaceFileFolder.workspaceId, workspaceId), isNull(workspaceFileFolder.deletedAt)) + and(eq(folderTable.workspaceId, workspaceId), isFileFolder, isNull(folderTable.deletedAt)) ) const paths = buildWorkspaceFileFolderPathMap(allFolders) return { @@ -1053,12 +1107,13 @@ export async function bulkArchiveWorkspaceFileItems(params: { const activeFolders = explicitFolderIds.length > 0 ? await tx - .select({ id: workspaceFileFolder.id, parentId: workspaceFileFolder.parentId }) - .from(workspaceFileFolder) + .select({ id: folderTable.id, parentId: folderTable.parentId }) + .from(folderTable) .where( and( - eq(workspaceFileFolder.workspaceId, params.workspaceId), - isNull(workspaceFileFolder.deletedAt) + eq(folderTable.workspaceId, params.workspaceId), + isFileFolder, + isNull(folderTable.deletedAt) ) ) : [] @@ -1102,16 +1157,17 @@ export async function bulkArchiveWorkspaceFileItems(params: { const archivedFolders = allFolderIds.length > 0 ? await tx - .update(workspaceFileFolder) + .update(folderTable) .set({ deletedAt: now, updatedAt: now }) .where( and( - inArray(workspaceFileFolder.id, allFolderIds), - eq(workspaceFileFolder.workspaceId, params.workspaceId), - isNull(workspaceFileFolder.deletedAt) + inArray(folderTable.id, allFolderIds), + eq(folderTable.workspaceId, params.workspaceId), + isFileFolder, + isNull(folderTable.deletedAt) ) ) - .returning({ id: workspaceFileFolder.id }) + .returning({ id: folderTable.id }) : [] return { From f7eda07b07310a1d4c66396cf72627967f3b0b7e Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 28 Jul 2026 19:53:48 -0700 Subject: [PATCH 02/11] fix(folders): close the folder-engine audit findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Data loss - Migration 0274 catches up file folders created AFTER 0272. That backfill is guarded by `WHERE NOT EXISTS (… resource_type = 'file')`, so it fires once; every file folder created between it and this cutover exists only in `workspace_file_folders`. Without the catch-up they vanish from Files on deploy and their contents become unreachable Authorization - Archived folders were mutable, which bypassed the folder lock: the folder routes and `updateFolder` filtered id + resourceType but never `deletedAt`, while `getFolderLockStatus` does — so an archived-but-locked folder reported unlocked. Lock a subfolder, delete its parent, and any write-level member could rename and reparent it. PUT and the engine now require an active row; DELETE deliberately still does not, because it reuses an archived folder's own `deletedAt` to make a partial cascade retryable - `v1/admin/workflows/import` wrote `folderId` with no validation at all. Migration 0272 dropped the FK, so it accepted a knowledge-base folder, another workspace's folder, or garbage — and the workflow then rendered under no sidebar node while still executing, billing, and escaping the folder delete cascade. It now runs the same `assertFolderInWorkspace` + `assertFolderMutable` pair as `v1/workflows/import` Restore no longer loses state - A folder restore ran a bare `archivedAt = null` over its workflows, while the delete had routed through `archiveWorkflow` — which also disables schedules, webhooks, chats, and MCP tools. Restoring a folder reported success while every schedule stayed disabled forever and every chat 404'd. The workflow tree now restores through `restoreWorkflow`, like the knowledge-base and table trees already did. Deployment state is still deliberately not revived, matching the single-workflow restore - Those canonical restores re-root a resource whose folder is archived — correct on its own, catastrophic inside a cascade, which restores children BEFORE the folder rows and would therefore dump every child at the workspace root. `resolveRestoredFolderId` takes the subtree being restored and exempts it. `restoreTable` gained the re-root check it lacked Correctness - `getFolderPath` in `lib/folders/tree.ts` walked `parentId` with no `visited` set, so a cycle in the client cache — reachable through `useReorderFolders`' unchecked optimistic write — hung the tab. Its twin already had one. Dead `buildFolderMap` deleted - The sidebar's "New folder" inside a folder hardcoded the name, so the second one 409'd, was swallowed with only a log line, and the user got no folder and no message. It now names through the existing `generateSubfolderName` and surfaces failures - `chunkedBatchDelete` deleted by id with no re-check, so a resource restored between the SELECT and the DELETE was hard-deleted anyway. Callers now re-assert their soft-delete predicate on the DELETE - Recently Deleted and the `restore_resource` tool cover every folder tree, not just workflow folders, so deleted knowledge-base and table folders are recoverable Cleanup - The folder duplicate route stops steering control flow through `throw new Error('literal')` matched by string, reuses the engine's `nextFolderSortOrder` instead of recomputing it, and names its workflow scope once - `servedFolderResourceTypeSchema` documents that its default applies only to an omitted value; a present-but-unrecognized one is rejected, never coerced to `workflow` - The `workspace_file_folders` comment described the table as unread and unwritten and invited a DROP. It was live until this cutover, and it is now the rollback copy — the comment says so, and names what must be true before anyone drops it - Pinned rows carry a small non-interactive pin glyph again; they sort to the top and had nothing explaining why since the inline pin button was removed Tests - New `lib/folders/lifecycle.test.ts` covers create/update/delete/restore, the re-root, and the 23505 mappings; `cleanup-soft-deletes.test.ts` now pins the folder target's resourceType predicate, which is what keeps the sweep off a not-yet-cut-over type --- .../app/api/folders/[id]/duplicate/route.ts | 139 ++-- apps/sim/app/api/folders/[id]/route.ts | 22 +- .../api/v1/admin/workflows/import/route.ts | 29 + .../components/folders/folder-row.tsx | 10 +- .../components/resource/resource.tsx | 21 +- .../[workspaceId]/knowledge/knowledge.tsx | 4 +- .../workspace/[workspaceId]/tables/tables.tsx | 4 +- .../components/folder-item/folder-item.tsx | 15 +- .../background/cleanup-soft-deletes.test.ts | 73 +- apps/sim/background/cleanup-soft-deletes.ts | 9 + apps/sim/hooks/queries/folders.ts | 6 + apps/sim/lib/api/contracts/folders.ts | 10 +- apps/sim/lib/cleanup/batch-delete.ts | 11 +- .../tools/handlers/restore-resource.ts | 11 +- apps/sim/lib/folders/config.ts | 40 +- apps/sim/lib/folders/lifecycle.test.ts | 674 ++++++++++++++++++ apps/sim/lib/folders/lifecycle.ts | 20 +- apps/sim/lib/folders/queries.ts | 24 + apps/sim/lib/folders/tree.ts | 18 +- apps/sim/lib/knowledge/service.ts | 19 +- .../orchestration/restore-resource.ts | 29 +- apps/sim/lib/table/service.ts | 28 +- apps/sim/lib/workflows/lifecycle.ts | 8 +- .../orchestration/folder-lifecycle.ts | 12 +- apps/sim/lib/workspaces/naming.ts | 5 +- ...4_file_folder_cutover_catchup_backfill.sql | 31 + packages/db/migrations/meta/_journal.json | 7 + packages/db/schema.ts | 17 +- 28 files changed, 1179 insertions(+), 117 deletions(-) create mode 100644 apps/sim/lib/folders/lifecycle.test.ts create mode 100644 packages/db/migrations/0274_file_folder_cutover_catchup_backfill.sql diff --git a/apps/sim/app/api/folders/[id]/duplicate/route.ts b/apps/sim/app/api/folders/[id]/duplicate/route.ts index 2e481ec551c..1dadf899014 100644 --- a/apps/sim/app/api/folders/[id]/duplicate/route.ts +++ b/apps/sim/app/api/folders/[id]/duplicate/route.ts @@ -4,7 +4,7 @@ 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' -import { and, eq, isNull, min } from 'drizzle-orm' +import { and, eq, isNull } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { duplicateFolderContract } from '@/lib/api/contracts' import { parseRequest } from '@/lib/api/server' @@ -12,6 +12,7 @@ import { getSession } from '@/lib/auth' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import type { DbOrTx } from '@/lib/db/types' +import { nextFolderSortOrder } from '@/lib/folders/lifecycle' import { deduplicateFolderName } from '@/lib/folders/naming' import { toFolderApi } from '@/lib/folders/queries' import { duplicateWorkflow } from '@/lib/workflows/persistence/duplicate' @@ -19,6 +20,30 @@ import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' const logger = createLogger('FolderDuplicateAPI') +/** + * Duplication only ever copies workflow folders. Named once so the scope is stated rather + * than restated as a literal at every query — the engine reads its resourceType from config + * for exactly this reason. + */ +const FOLDER_RESOURCE_TYPE = 'workflow' as const + +/** + * Carries the HTTP status with the failure, so the handler maps errors by type instead of by + * comparing `error.message` to a literal — a coupling that breaks silently the moment + * someone rewords a message. + */ +class FolderDuplicationError extends Error { + constructor( + message: string, + readonly status: number, + /** Message returned to the caller when it must differ from the logged one. */ + readonly publicMessage: string = message + ) { + super(message) + this.name = 'FolderDuplicationError' + } +} + // POST /api/folders/[id]/duplicate - Duplicate a folder with all its child folders and workflows export const POST = withRouteHandler( async (req: NextRequest, context: { params: Promise<{ id: string }> }) => { @@ -46,13 +71,13 @@ export const POST = withRouteHandler( and( eq(folderTable.id, sourceFolderId), isNull(folderTable.deletedAt), - eq(folderTable.resourceType, 'workflow') + eq(folderTable.resourceType, FOLDER_RESOURCE_TYPE) ) ) .then((rows) => rows[0]) if (!sourceFolder) { - throw new Error('Source folder not found') + throw new FolderDuplicationError('Source folder not found', 404) } const userPermission = await getUserEntityPermissions( @@ -62,12 +87,16 @@ export const POST = withRouteHandler( ) if (!userPermission || userPermission === 'read') { - throw new Error('Source folder not found or access denied') + throw new FolderDuplicationError( + 'Source folder not found or access denied', + 403, + 'Access denied' + ) } const targetWorkspaceId = workspaceId || sourceFolder.workspaceId if (targetWorkspaceId !== sourceFolder.workspaceId) { - throw new Error('Cross-workspace folder duplication is not supported') + throw new FolderDuplicationError('Cross-workspace folder duplication is not supported', 400) } const { newFolderId, folderMapping, workflowStats } = await db.transaction(async (tx) => { @@ -76,49 +105,26 @@ export const POST = withRouteHandler( const targetParentId = parentId ?? sourceFolder.parentId await assertTargetParentFolderMutable(tx, targetParentId, targetWorkspaceId, sourceFolderId) - const folderParentCondition = targetParentId - ? 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(folderTable.sortOrder) }) - .from(folderTable) - .where( - and( - eq(folderTable.workspaceId, targetWorkspaceId), - eq(folderTable.resourceType, 'workflow'), - folderParentCondition - ) - ), + // Placement is the engine's rule (folders and workflows share one ordering space), + // so it is read from there rather than recomputed here. + const sortOrder = await nextFolderSortOrder( + FOLDER_RESOURCE_TYPE, + targetWorkspaceId, + targetParentId, tx - .select({ minSortOrder: min(workflow.sortOrder) }) - .from(workflow) - .where(and(eq(workflow.workspaceId, targetWorkspaceId), workflowParentCondition)), - ]) - - const minSortOrder = [folderResult?.minSortOrder, workflowResult?.minSortOrder].reduce< - number | null - >((currentMin, candidate) => { - if (candidate == null) return currentMin - if (currentMin == null) return candidate - return Math.min(currentMin, candidate) - }, null) - const sortOrder = minSortOrder != null ? minSortOrder - 1 : 0 + ) + const deduplicatedName = await deduplicateFolderName( tx, targetWorkspaceId, targetParentId, name, - 'workflow' + FOLDER_RESOURCE_TYPE ) await tx.insert(folderTable).values({ id: newFolderId, - resourceType: 'workflow', + resourceType: FOLDER_RESOURCE_TYPE, userId: session.user.id, workspaceId: targetWorkspaceId, name: deduplicatedName, @@ -183,41 +189,23 @@ export const POST = withRouteHandler( const duplicatedFolder = await db .select() .from(folderTable) - .where(and(eq(folderTable.id, newFolderId), eq(folderTable.resourceType, 'workflow'))) + .where( + and(eq(folderTable.id, newFolderId), eq(folderTable.resourceType, FOLDER_RESOURCE_TYPE)) + ) .then((rows) => rows[0]) return NextResponse.json({ folder: toFolderApi(duplicatedFolder) }, { status: 201 }) } catch (error) { - if (error instanceof Error) { - if (error instanceof FolderLockedError) { - return NextResponse.json({ error: error.message }, { status: error.status }) - } - - if (error.message === 'Source folder not found') { - logger.warn(`[${requestId}] Source folder ${sourceFolderId} not found`) - return NextResponse.json({ error: 'Source folder not found' }, { status: 404 }) - } - - if (error.message === 'Source folder not found or access denied') { - logger.warn( - `[${requestId}] User ${session.user.id} denied access to source folder ${sourceFolderId}` - ) - return NextResponse.json({ error: 'Access denied' }, { status: 403 }) - } - - if (error.message === 'Cross-workspace folder duplication is not supported') { - logger.warn( - `[${requestId}] User ${session.user.id} attempted cross-workspace folder duplication for ${sourceFolderId}` - ) - return NextResponse.json({ error: error.message }, { status: 400 }) - } + if (error instanceof FolderLockedError) { + return NextResponse.json({ error: error.message }, { status: error.status }) + } - if ( - error.message === 'Target parent folder not found' || - error.message === 'Cannot duplicate folder into itself or one of its descendants' - ) { - return NextResponse.json({ error: error.message }, { status: 400 }) - } + if (error instanceof FolderDuplicationError) { + logger.warn(`[${requestId}] Folder duplication rejected: ${error.message}`, { + sourceFolderId, + userId: session.user.id, + }) + return NextResponse.json({ error: error.publicMessage }, { status: error.status }) } const elapsed = Date.now() - startTime @@ -250,14 +238,19 @@ async function assertTargetParentFolderMutable( archivedAt: folderTable.deletedAt, }) .from(folderTable) - .where(and(eq(folderTable.id, currentFolderId), eq(folderTable.resourceType, 'workflow'))) + .where( + and(eq(folderTable.id, currentFolderId), eq(folderTable.resourceType, FOLDER_RESOURCE_TYPE)) + ) .limit(1) if (!folder || folder.workspaceId !== targetWorkspaceId || folder.archivedAt) { - throw new Error('Target parent folder not found') + throw new FolderDuplicationError('Target parent folder not found', 400) } if (folder.id === sourceFolderId) { - throw new Error('Cannot duplicate folder into itself or one of its descendants') + throw new FolderDuplicationError( + 'Cannot duplicate folder into itself or one of its descendants', + 400 + ) } if (folder.locked) { throw new FolderLockedError() @@ -284,7 +277,7 @@ async function duplicateFolderStructure( and( eq(folderTable.parentId, sourceFolderId), eq(folderTable.workspaceId, sourceWorkspaceId), - eq(folderTable.resourceType, 'workflow'), + eq(folderTable.resourceType, FOLDER_RESOURCE_TYPE), isNull(folderTable.deletedAt) ) ) @@ -295,7 +288,7 @@ async function duplicateFolderStructure( await tx.insert(folderTable).values({ id: newChildFolderId, - resourceType: 'workflow', + resourceType: FOLDER_RESOURCE_TYPE, userId, workspaceId: targetWorkspaceId, name: childFolder.name, diff --git a/apps/sim/app/api/folders/[id]/route.ts b/apps/sim/app/api/folders/[id]/route.ts index 5b237776b63..176e43a9aba 100644 --- a/apps/sim/app/api/folders/[id]/route.ts +++ b/apps/sim/app/api/folders/[id]/route.ts @@ -2,7 +2,7 @@ import { db } from '@sim/db' import { folder as folderTable } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { assertFolderMutable, FolderLockedError } from '@sim/platform-authz/workflow' -import { and, eq } from 'drizzle-orm' +import { and, eq, isNull } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { deleteFolderContract, updateFolderContract } from '@/lib/api/contracts' import { parseRequest } from '@/lib/api/server' @@ -45,10 +45,22 @@ export const PUT = withRouteHandler( const { resourceType } = parsed.data.query const { name, locked, parentId, sortOrder } = parsed.data.body + /** + * `isNull(deletedAt)` is load-bearing, not tidiness: `getFolderLockStatus` skips + * archived rows, so an archived-but-locked folder reports unlocked. Without this + * filter, deleting a folder makes every locked subfolder under it freely renameable + * and reparentable by any write-level member. + */ const existingFolder = await db .select() .from(folderTable) - .where(and(eq(folderTable.id, id), eq(folderTable.resourceType, resourceType))) + .where( + and( + eq(folderTable.id, id), + eq(folderTable.resourceType, resourceType), + isNull(folderTable.deletedAt) + ) + ) .then((rows) => rows[0]) if (!existingFolder) { @@ -143,6 +155,12 @@ export const DELETE = withRouteHandler( const { id } = parsed.data.params const { resourceType } = parsed.data.query + /** + * Deliberately NOT filtered on `deletedAt`, unlike PUT above: `deleteFolder` reuses an + * already-archived folder's own `deletedAt` so a cascade that failed partway can be + * retried onto the same snapshot. 404ing here would strand those stragglers. Delete is + * also idempotent, so re-reaching an archived folder grants nothing new. + */ const existingFolder = await db .select() .from(folderTable) diff --git a/apps/sim/app/api/v1/admin/workflows/import/route.ts b/apps/sim/app/api/v1/admin/workflows/import/route.ts index 502c2592b0e..732c4e4d6fe 100644 --- a/apps/sim/app/api/v1/admin/workflows/import/route.ts +++ b/apps/sim/app/api/v1/admin/workflows/import/route.ts @@ -17,6 +17,12 @@ import { db } from '@sim/db' import { workflow, workspace } from '@sim/db/schema' import { createLogger } from '@sim/logger' +import { + assertFolderInWorkspace, + assertFolderMutable, + FolderLockedError, + FolderNotFoundError, +} from '@sim/platform-authz/workflow' import { generateId } from '@sim/utils/id' import { and, eq, isNull } from 'drizzle-orm' import { NextResponse } from 'next/server' @@ -63,6 +69,23 @@ export const POST = withRouteHandler( return notFoundResponse('Workspace') } + /** + * Migration 0272 dropped the FK on `workflow.folder_id`, so nothing but this check + * stands between the caller and a folder in another workspace — or one from another + * resource's tree. A workflow filed under an unreachable folder still executes and + * bills, escapes the folder delete cascade, and never counts toward + * `guardLastWorkflows`. + * + * Ownership before lock state, mirroring `v1/workflows/import`: `assertFolderMutable` + * walks the ancestor chain without filtering on workspace, so checking it first would + * let a caller distinguish a locked folder in someone else's workspace (423) from a + * nonexistent one (404). + */ + if (folderId) { + await assertFolderInWorkspace(folderId, workspaceId) + } + await assertFolderMutable(folderId ?? null) + const workflowContent = typeof body.workflow === 'string' ? body.workflow : JSON.stringify(body.workflow) @@ -148,6 +171,12 @@ export const POST = withRouteHandler( return NextResponse.json(response) } catch (error) { + if (error instanceof FolderNotFoundError) { + return badRequestResponse(error.message) + } + if (error instanceof FolderLockedError) { + return NextResponse.json({ error: error.message }, { status: error.status }) + } logger.error('Admin API: Failed to import workflow', { error }) return internalErrorResponse('Failed to import workflow') } diff --git a/apps/sim/app/workspace/[workspaceId]/components/folders/folder-row.tsx b/apps/sim/app/workspace/[workspaceId]/components/folders/folder-row.tsx index 25e0ee3e65c..10b9bf856b1 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/folders/folder-row.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/folders/folder-row.tsx @@ -9,6 +9,13 @@ import type { WorkflowFolder } from '@/stores/folders/types' const FOLDER_ICON = export interface FolderRowOptions { + /** + * Whether this folder is pinned. Folders pin under `resourceType: 'folder'`, a different + * pin namespace from the resource they contain — a page listing both resolves two + * `usePinnedIds` sets and passes the right one here. Drives the glyph only; the pin action + * lives on the row's context menu. + */ + pinned?: boolean /** * Cells for the page's non-name columns, keyed by `ResourceColumn.id`. Folders have no * value for most resource-specific columns, so a page passes either a derived roll-up or @@ -30,7 +37,7 @@ export interface FolderRowOptions { * field rebuilds one cell instead of every row's cells. */ export function folderRow(folder: WorkflowFolder, options: FolderRowOptions = {}): ResourceRow { - const { cells, nameColumnId = 'name' } = options + const { pinned, cells, nameColumnId = 'name' } = options return { id: folderRowId(folder.id), @@ -39,6 +46,7 @@ export function folderRow(folder: WorkflowFolder, options: FolderRowOptions = {} [nameColumnId]: { icon: FOLDER_ICON, label: folder.name, + pinned, }, }, } diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx index ed960210aa6..2166584a962 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx @@ -21,6 +21,7 @@ import { cn, Loader, } from '@sim/emcn' +import { Pin } from '@sim/emcn/icons' import { useVirtualizer } from '@tanstack/react-virtual' import { ChevronLeft, ChevronRight } from 'lucide-react' import { InlineRenameInput } from '@/app/workspace/[workspaceId]/components/inline-rename-input' @@ -58,6 +59,13 @@ export interface ResourceCell { * resting cell exactly (same gap, weight, icon size). */ editing?: ResourceCellEditing + /** + * Marks the row as pinned. Renders a small non-interactive glyph after the label — pinned + * rows sort to the top of every list, and without an indicator that ordering reads as + * arbitrary. Pinning itself is an action on the row's context menu, deliberately not a + * hover button here. + */ + pinned?: boolean } export interface ResourceRow { @@ -488,9 +496,16 @@ interface CellContentProps { label: string content?: ReactNode editing?: ResourceCellEditing + pinned?: boolean } -const CellContent = memo(function CellContent({ icon, label, content, editing }: CellContentProps) { +const CellContent = memo(function CellContent({ + icon, + label, + content, + editing, + pinned, +}: CellContentProps) { if (editing) { return ( @@ -510,6 +525,9 @@ const CellContent = memo(function CellContent({ icon, label, content, editing }: {icon && {icon}} + {pinned && ( + + )} ) }) @@ -681,6 +699,7 @@ const DataRow = memo(function DataRow({ label={cell?.label || EMPTY_CELL_PLACEHOLDER} content={cell?.content} editing={cell?.editing} + pinned={cell?.pinned} />
) diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx index cb7891c3a28..4a990bb89a1 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx @@ -491,6 +491,7 @@ export function Knowledge() { const baseRows: ResourceRow[] = useMemo(() => { const folderRows = visibleFolders.map((folder) => folderRow(folder, { + pinned: pinnedFolderIds.has(folder.id), cells: { documents: { label: EMPTY_CELL_PLACEHOLDER }, tokens: { label: EMPTY_CELL_PLACEHOLDER }, @@ -510,6 +511,7 @@ export function Knowledge() { name: { icon: KNOWLEDGE_BASE_ICON, label: kb.name, + pinned: pinnedBaseIds.has(kb.id), }, documents: { label: String(kbWithCount.docCount || 0), @@ -526,7 +528,7 @@ export function Knowledge() { }) return [...folderRows, ...knowledgeBaseRows] - }, [visibleFolders, processedKBs, members]) + }, [visibleFolders, processedKBs, members, pinnedFolderIds, pinnedBaseIds]) /** * Rename is layered over the built rows rather than folded into the builder above, so a diff --git a/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx b/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx index 328ac0edc9c..29bb86e2277 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx @@ -364,6 +364,7 @@ export function Tables() { const baseRows: ResourceRow[] = useMemo(() => { const folderRows = visibleFolders.map((folder) => folderRow(folder, { + pinned: pinnedFolderIds.has(folder.id), cells: { columns: { label: EMPTY_CELL_PLACEHOLDER }, rows: { label: EMPTY_CELL_PLACEHOLDER }, @@ -381,6 +382,7 @@ export function Tables() { name: { icon: , label: table.name, + pinned: pinnedTableIds.has(table.id), }, columns: { icon: , @@ -398,7 +400,7 @@ export function Tables() { ) return [...folderRows, ...tableRows] - }, [visibleFolders, processedTables, membersById]) + }, [visibleFolders, processedTables, membersById, pinnedFolderIds, pinnedTableIds]) /** * Layered on top of {@link baseRows} rather than folded into it so a keystroke 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..9cec8e5a508 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 @@ -1,14 +1,16 @@ 'use client' import { memo, useCallback, useMemo, useRef, useState } from 'react' -import { chipVariants, cn } from '@sim/emcn' +import { chipVariants, cn, toast } from '@sim/emcn' import { Lock } from '@sim/emcn/icons' import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import clsx from 'clsx' import { ChevronRight, Folder, FolderOpen, MoreHorizontal } from 'lucide-react' import { useRouter } from 'next/navigation' import { SIM_RESOURCES_DRAG_TYPE } from '@/lib/copilot/resource-types' +import { generateSubfolderName } from '@/lib/workspaces/naming' 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' import { DeleteModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/delete-modal/delete-modal' @@ -165,9 +167,17 @@ export const FolderItem = memo(function FolderItem({ workspaceId, folder }: Fold const handleCreateFolderInFolder = useCallback(async () => { if (effectiveLocked) return try { + /** + * The name has to be unique before it is sent: `folder` has a partial unique index on + * active (workspaceId, resourceType, parentId, name), so a hardcoded 'New folder' + * 409s on the second invocation — and the user never chose this name, so there is + * nothing for them to correct. Mirrors the root-level create in + * `use-folder-operations`, which already names through this helper. + */ + const name = await generateSubfolderName(workspaceId, folder.id) const result = await createFolderMutation.mutateAsync({ workspaceId, - name: 'New folder', + name, parentId: folder.id, id: generateId(), }) @@ -179,6 +189,7 @@ export const FolderItem = memo(function FolderItem({ workspaceId, folder }: Fold } } catch (error) { logger.error('Failed to create folder:', error) + toast.error(getErrorMessage(error, 'Failed to create folder')) } }, [createFolderMutation, workspaceId, folder.id, effectiveLocked, expandFolder]) diff --git a/apps/sim/background/cleanup-soft-deletes.test.ts b/apps/sim/background/cleanup-soft-deletes.test.ts index a52108e5d36..67a0655393b 100644 --- a/apps/sim/background/cleanup-soft-deletes.test.ts +++ b/apps/sim/background/cleanup-soft-deletes.test.ts @@ -2,7 +2,7 @@ * @vitest-environment node */ -import { dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing' +import { dbChainMock, dbChainMockFns, resetDbChainMock, schemaMock } from '@sim/testing' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' const { @@ -261,3 +261,74 @@ describe('cleanup soft deletes', () => { expect(mockDeleteFileMetadata).not.toHaveBeenCalled() }) }) + +interface BatchDeleteOptions { + tableDef: unknown + workspaceIdCol: unknown + timestampCol: unknown + tableName: string + requireTimestampNotNull?: boolean + additionalPredicate?: { type: string; column: unknown; values: unknown[] } +} + +/** + * The `folder` table is shared by all four foldered resource types and is the one cleanup + * target carrying an `additionalPredicate`. That predicate is the only thing standing + * between this sweep and hard-deleting rows of a resource type whose cutover has not landed. + */ +describe('folder cleanup target', () => { + async function runAndFindFolderTarget(): Promise { + await runCleanupSoftDeletes(basePayload) + const calls = mockBatchDeleteByWorkspaceAndTimestamp.mock.calls as unknown as Array< + [BatchDeleteOptions] + > + return calls.find(([options]) => options.tableName === 'free/1/folder')?.[0] + } + + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mockSelectRowsByIdChunks.mockReset().mockResolvedValue([]) + mockChunkedBatchDelete.mockReset().mockResolvedValue({ deleted: 0, failed: 0 }) + }) + + it('sweeps expired folder rows off the folder table, keyed on its soft-delete column', async () => { + const target = await runAndFindFolderTarget() + + expect(target).toBeDefined() + expect(target?.tableDef).toBe(schemaMock.folder) + expect(target?.timestampCol).toBe(schemaMock.folder.deletedAt) + expect(target?.workspaceIdCol).toBe(schemaMock.folder.workspaceId) + // Without this, a folder whose deletedAt is null would be swept by the retention cutoff. + expect(target?.requireTimestampNotNull).toBe(true) + }) + + it('narrows the folder sweep to the resource types whose cutover has landed', async () => { + // Regression guard for "simplifying" the predicate away: `folder` is one table shared by + // every resource type, so an unfiltered sweep would hard-delete rows belonging to a type + // added to the enum before its own delete/restore path exists. + const target = await runAndFindFolderTarget() + + expect(target?.additionalPredicate).toBeDefined() + expect(target?.additionalPredicate?.type).toBe('inArray') + expect(target?.additionalPredicate?.column).toBe(schemaMock.folder.resourceType) + expect(target?.additionalPredicate?.values).toEqual([ + 'workflow', + 'file', + 'knowledge_base', + 'table', + ]) + }) + + it('leaves every other cleanup target unfiltered so only folder pays for the predicate', async () => { + await runCleanupSoftDeletes(basePayload) + const calls = mockBatchDeleteByWorkspaceAndTimestamp.mock.calls as unknown as Array< + [BatchDeleteOptions] + > + + const filtered = calls + .filter(([options]) => options.additionalPredicate !== undefined) + .map(([options]) => options.tableName) + expect(filtered).toEqual(['free/1/folder']) + }) +}) diff --git a/apps/sim/background/cleanup-soft-deletes.ts b/apps/sim/background/cleanup-soft-deletes.ts index 419a98496bd..cb5e8a063ea 100644 --- a/apps/sim/background/cleanup-soft-deletes.ts +++ b/apps/sim/background/cleanup-soft-deletes.ts @@ -397,6 +397,15 @@ async function cleanupExpiredKnowledgeBases( ) ) .limit(limit), + /** + * Re-asserted on the DELETE because `onBatch` hard-deletes documents first, which leaves + * a real window in which a restore can land. Without it, a knowledge base restored in + * that window is hard-deleted anyway. + */ + deleteFilter: and( + isNotNull(knowledgeBase.deletedAt), + lt(knowledgeBase.deletedAt, retentionDate) + ), onBatch: (rows) => hardDeleteKnowledgeBaseDocuments( rows.map(({ id }) => id), diff --git a/apps/sim/hooks/queries/folders.ts b/apps/sim/hooks/queries/folders.ts index 13bdee285b3..6dec5c6a547 100644 --- a/apps/sim/hooks/queries/folders.ts +++ b/apps/sim/hooks/queries/folders.ts @@ -335,6 +335,12 @@ export function useRestoreFolder() { }) } +/** + * Workflow-only by design, unlike the other folder mutations in this file: duplication copies + * the workflows inside the folder, and `POST /api/folders/[id]/duplicate` has no equivalent + * for knowledge bases or tables. The `resourceType: 'workflow'` below is that constraint, not + * an oversight — generalizing it would optimistically insert a folder the route then refuses. + */ export function useDuplicateFolderMutation() { const queryClient = useQueryClient() diff --git a/apps/sim/lib/api/contracts/folders.ts b/apps/sim/lib/api/contracts/folders.ts index a94257aee26..6a9c7ea4ac3 100644 --- a/apps/sim/lib/api/contracts/folders.ts +++ b/apps/sim/lib/api/contracts/folders.ts @@ -10,9 +10,17 @@ export type FolderResourceType = z.output * {@link folderResourceTypeSchema} now that every type is backed by the `folder` table; * kept as a separate schema so a future resource type can be added to the enum (and the * DB) ahead of the routes that serve it. + * + * The `.default` applies ONLY to an omitted value — that is what keeps an old client, which + * never sends the field, working against a new pod. A value that is present but not in the + * enum is REJECTED with a 400, never silently coerced to `'workflow'`: coercing would file a + * knowledge-base folder into the workflow tree, where the Knowledge page can never see it + * again. This is the deploy-ordering contract — see the note in the PR description. */ export const servedFolderResourceTypeSchema = z - .enum(['workflow', 'file', 'knowledge_base', 'table']) + .enum(['workflow', 'file', 'knowledge_base', 'table'], { + error: 'resourceType must be one of workflow, file, knowledge_base, table', + }) .default('workflow') export type ServedFolderResourceType = z.output diff --git a/apps/sim/lib/cleanup/batch-delete.ts b/apps/sim/lib/cleanup/batch-delete.ts index 6e51c6ceb77..f7e6b8f3b09 100644 --- a/apps/sim/lib/cleanup/batch-delete.ts +++ b/apps/sim/lib/cleanup/batch-delete.ts @@ -81,6 +81,14 @@ export interface ChunkedBatchDeleteOptions { selectChunk: (chunkIds: string[], limit: number) => Promise /** Runs between SELECT and DELETE; receives the just-selected rows. */ onBatch?: (rows: TRow[]) => Promise + /** + * Re-asserted on the DELETE alongside the id list. Soft-delete sweeps MUST pass the same + * predicate they selected on: the DELETE runs after the SELECT and after `onBatch`, and a + * row restored in that window would otherwise be hard-deleted — taking the children a + * folder restore had just brought back with it. Rows that no longer match are simply not + * deleted and are counted as failed, so the next run re-evaluates them. + */ + deleteFilter?: SQL batchSize?: number /** Max batches per workspace chunk. */ maxBatches?: number @@ -112,6 +120,7 @@ export async function chunkedBatchDelete({ tableName, selectChunk, onBatch, + deleteFilter, batchSize = DEFAULT_BATCH_SIZE, maxBatches = DEFAULT_MAX_BATCHES_PER_TABLE, totalRowLimit = DEFAULT_BATCH_SIZE * DEFAULT_MAX_BATCHES_PER_TABLE, @@ -161,7 +170,7 @@ export async function chunkedBatchDelete({ const ids = rows.map((r) => r.id) const deleted = await dbClient .delete(tableDef) - .where(inArray(sql`id`, ids)) + .where(deleteFilter ? and(inArray(sql`id`, ids), deleteFilter) : inArray(sql`id`, ids)) .returning({ id: sql`id` }) result.deleted += deleted.length diff --git a/apps/sim/lib/copilot/tools/handlers/restore-resource.ts b/apps/sim/lib/copilot/tools/handlers/restore-resource.ts index 816efb1660f..fa57b17b1b6 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', + 'knowledge_folder', + 'table_folder', +]) export async function executeRestoreResource( rawParams: Record, diff --git a/apps/sim/lib/folders/config.ts b/apps/sim/lib/folders/config.ts index 6fd76dae2f0..b99b02d92c4 100644 --- a/apps/sim/lib/folders/config.ts +++ b/apps/sim/lib/folders/config.ts @@ -256,6 +256,37 @@ async function selectChildIds( return rows.map((row) => String(row.id)) } +/** + * Restores the workflows this cascade archived, through the canonical workflow restore. + * + * A bare `workflow.archivedAt = null` is NOT the inverse of the archive: `archiveWorkflow` + * also archives the workflow's schedules (status `disabled`, `nextRunAt` cleared), webhooks + * (`isActive: false`), chats (`isActive: false`), and MCP tools. Restoring only the workflow + * row reports success while every schedule stays disabled forever and every chat 404s — + * silently, because the workflow itself looks fine. + * + * Deployment state is deliberately NOT revived: `isDeployed`/`isPublicApi` and + * `workflowDeploymentVersion.isActive` stay off, so a restore never re-exposes a public + * endpoint without an explicit redeploy. That matches the single-workflow restore path. + */ +async function restoreWorkflowChildren(context: CascadeChildrenContext): Promise { + const { restoreWorkflow } = await import('@/lib/workflows/lifecycle') + const ids = await selectChildIds(FOLDER_RESOURCES.workflow, context, 'archived') + + const restoringFolderIds = new Set(context.folderIds) + + let restored = 0 + for (const id of ids) { + const result = await restoreWorkflow(id, { + requestId: `folder-cascade-${context.folderIds[0]}`, + restoringFolderIds, + }) + if (result.restored) restored += 1 + } + + return restored +} + /** * Archives the knowledge bases in a folder subtree through the canonical KB delete, which * also archives their documents and pauses their connectors. A bare `knowledge_base` row @@ -283,9 +314,12 @@ async function archiveKnowledgeBaseChildren(context: CascadeChildrenContext): Pr async function restoreKnowledgeBaseChildren(context: CascadeChildrenContext): Promise { const { restoreKnowledgeBase } = await import('@/lib/knowledge/service') const ids = await selectChildIds(FOLDER_RESOURCES.knowledge_base, context, 'archived') + const restoringFolderIds = new Set(context.folderIds) for (const id of ids) { - await restoreKnowledgeBase(id, `folder-cascade-${context.folderIds[0]}`) + await restoreKnowledgeBase(id, `folder-cascade-${context.folderIds[0]}`, { + restoringFolderIds, + }) } return ids.length @@ -317,9 +351,10 @@ async function archiveTableChildren(context: CascadeChildrenContext): Promise { const { restoreTable } = await import('@/lib/table/service') const ids = await selectChildIds(FOLDER_RESOURCES.table, context, 'archived') + const restoringFolderIds = new Set(context.folderIds) for (const id of ids) { - await restoreTable(id, `folder-cascade-${context.folderIds[0]}`) + await restoreTable(id, `folder-cascade-${context.folderIds[0]}`, { restoringFolderIds }) } return ids.length @@ -416,6 +451,7 @@ export const FOLDER_RESOURCES: Record ], supportsLocking: true, archiveChildren: archiveWorkflowChildren, + restoreChildren: restoreWorkflowChildren, guardDelete: guardLastWorkflows, }, file: { diff --git a/apps/sim/lib/folders/lifecycle.test.ts b/apps/sim/lib/folders/lifecycle.test.ts new file mode 100644 index 00000000000..d6a7ee2f038 --- /dev/null +++ b/apps/sim/lib/folders/lifecycle.test.ts @@ -0,0 +1,674 @@ +/** + * @vitest-environment node + */ +import { + auditMock, + dbChainMock, + dbChainMockFns, + queueTableRows, + resetDbChainMock, + schemaMock, +} from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockArchiveFolderCascade, + mockCollectArchivedSubtreeIds, + mockCollectCascadeSubtreeIds, + mockDeduplicateFolderName, + mockGetWorkspaceWithOwner, + mockGuardDelete, + mockRestoreChildren, + mockRestoreFolderChildren, + mockRestoreFolderRows, + mockWouldCreateFolderCycle, + resourceConfig, +} = vi.hoisted(() => ({ + mockArchiveFolderCascade: vi.fn(), + mockCollectArchivedSubtreeIds: vi.fn(), + mockCollectCascadeSubtreeIds: vi.fn(), + mockDeduplicateFolderName: vi.fn(), + mockGetWorkspaceWithOwner: vi.fn(), + mockGuardDelete: vi.fn(), + mockRestoreChildren: vi.fn(), + mockRestoreFolderChildren: vi.fn(), + mockRestoreFolderRows: vi.fn(), + mockWouldCreateFolderCycle: vi.fn(), + resourceConfig: { current: {} as Record }, +})) + +vi.mock('@sim/audit', () => auditMock) + +vi.mock('@/lib/folders/cascade', () => ({ + archiveFolderCascade: mockArchiveFolderCascade, + collectArchivedSubtreeIds: mockCollectArchivedSubtreeIds, + collectCascadeSubtreeIds: mockCollectCascadeSubtreeIds, + restoreFolderChildren: mockRestoreFolderChildren, + restoreFolderRows: mockRestoreFolderRows, + toCascadeCounts: ( + config: { countKey: string }, + counts: { folders: number; children: number } + ) => ({ folders: counts.folders, [config.countKey]: counts.children }), +})) + +vi.mock('@/lib/folders/config', () => ({ + folderResourceConfig: () => resourceConfig.current, +})) + +vi.mock('@/lib/folders/naming', () => ({ deduplicateFolderName: mockDeduplicateFolderName })) + +vi.mock('@/lib/folders/queries', () => ({ wouldCreateFolderCycle: mockWouldCreateFolderCycle })) + +vi.mock('@/lib/workspaces/permissions/utils', () => ({ + getWorkspaceWithOwner: mockGetWorkspaceWithOwner, +})) + +import { createFolder, deleteFolder, restoreFolder, updateFolder } from '@/lib/folders/lifecycle' + +const CHILD_TABLE = { name: 'child_table' } + +/** Stand-in for the per-resource config; each test declares only the deltas it exercises. */ +function setConfig(overrides: Record = {}) { + resourceConfig.current = { + resourceType: 'table', + label: 'table', + countKey: 'tables', + table: CHILD_TABLE, + idColumn: 'child.id', + folderIdColumn: 'child.folderId', + workspaceColumn: 'child.workspaceId', + deletedColumn: 'child.archivedAt', + deletedKey: 'archivedAt', + ...overrides, + } +} + +/** Shaped like what the `postgres` driver throws on the active-name partial unique index. */ +function uniqueViolation(): Error { + return Object.assign(new Error('duplicate key value violates unique constraint'), { + code: '23505', + }) +} + +const DUPLICATE_NAME_ERROR = 'A folder with this name already exists in this location' + +const ARCHIVED_AT = new Date('2026-01-01T00:00:00.000Z') + +function folderRow(overrides: Record = {}) { + return { + id: 'folder-1', + resourceType: 'table', + name: 'Reports', + userId: 'user-1', + workspaceId: 'ws-1', + parentId: null, + locked: false, + sortOrder: 0, + createdAt: new Date('2025-12-01T00:00:00.000Z'), + updatedAt: new Date('2025-12-01T00:00:00.000Z'), + deletedAt: null, + ...overrides, + } +} + +const baseCreate = { + resourceType: 'table' as const, + userId: 'user-1', + workspaceId: 'ws-1', + name: 'Reports', +} + +beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + setConfig() + mockWouldCreateFolderCycle.mockResolvedValue(false) + mockDeduplicateFolderName.mockImplementation( + async (_tx: unknown, _ws: string, _parent: string | null, name: string) => name + ) + mockGetWorkspaceWithOwner.mockResolvedValue({ id: 'ws-1', archivedAt: null }) + mockCollectCascadeSubtreeIds.mockResolvedValue(['folder-1']) + mockCollectArchivedSubtreeIds.mockResolvedValue(['folder-1']) + mockArchiveFolderCascade.mockResolvedValue({ folders: 1, children: 0 }) + mockRestoreFolderRows.mockResolvedValue(1) + mockRestoreFolderChildren.mockResolvedValue(0) +}) + +afterAll(() => { + resetDbChainMock() +}) + +describe('createFolder', () => { + it('inserts the trimmed name and returns the created row', async () => { + queueTableRows(schemaMock.folder, [{ minSortOrder: 5 }]) + dbChainMockFns.returning.mockResolvedValueOnce([folderRow()]) + + const result = await createFolder({ ...baseCreate, id: 'folder-1', name: ' Reports ' }) + + expect(result).toEqual({ success: true, folder: folderRow() }) + expect(dbChainMockFns.values).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'folder-1', + resourceType: 'table', + name: 'Reports', + workspaceId: 'ws-1', + parentId: null, + }) + ) + }) + + it('refuses a parent that does not exist', async () => { + queueTableRows(schemaMock.folder, []) + + const result = await createFolder({ ...baseCreate, parentId: 'missing' }) + + expect(result).toEqual({ + success: false, + error: 'Parent folder not found', + errorCode: 'validation', + }) + expect(dbChainMockFns.values).not.toHaveBeenCalled() + }) + + it('refuses a parent that belongs to another workspace', async () => { + // The parent row exists and matches the resourceType, so only the workspace check stands + // between a caller-supplied id and filing a folder under another tenant's tree. + queueTableRows(schemaMock.folder, [{ workspaceId: 'ws-other', archivedAt: null }]) + + const result = await createFolder({ ...baseCreate, parentId: 'parent-1' }) + + expect(result.errorCode).toBe('validation') + expect(dbChainMockFns.values).not.toHaveBeenCalled() + }) + + it('scopes the parent lookup to the same resourceType so the DB trigger never has to', async () => { + // `folder_parent_resource_type_match` enforces this too; skipping it here would surface a + // raw trigger error as a 500 instead of a 400. + queueTableRows(schemaMock.folder, []) + + await createFolder({ ...baseCreate, resourceType: 'knowledge_base', parentId: 'parent-1' }) + + const [where] = dbChainMockFns.where.mock.calls[0] as [{ conditions: unknown[] }] + expect(where.conditions).toContainEqual(expect.objectContaining({ right: 'knowledge_base' })) + }) + + it('refuses an archived parent so a folder cannot be created inside a deleted tree', async () => { + queueTableRows(schemaMock.folder, [{ workspaceId: 'ws-1', archivedAt: ARCHIVED_AT }]) + + const result = await createFolder({ ...baseCreate, parentId: 'parent-1' }) + + expect(result.errorCode).toBe('validation') + expect(dbChainMockFns.values).not.toHaveBeenCalled() + }) + + it('refuses a folder that names itself as its own parent', async () => { + const result = await createFolder({ ...baseCreate, id: 'folder-1', parentId: 'folder-1' }) + + expect(result).toEqual({ + success: false, + error: 'Folder cannot be its own parent', + errorCode: 'validation', + }) + // Rejected before any read, so a self-parented id never reaches the parent lookup. + expect(dbChainMockFns.select).not.toHaveBeenCalled() + }) + + it('places a new folder above every existing sibling folder and resource', async () => { + // Workflows interleave folders and rows in one ordering space, so the new folder has to + // clear the minimum of BOTH — clearing only the folders would bury it under a workflow. + setConfig({ + resourceType: 'workflow', + countKey: 'workflows', + sortOrderColumn: 'child.sortOrder', + }) + queueTableRows(schemaMock.folder, [{ minSortOrder: 3 }]) + queueTableRows(CHILD_TABLE, [{ minSortOrder: -2 }]) + dbChainMockFns.returning.mockResolvedValueOnce([folderRow({ sortOrder: -3 })]) + + await createFolder({ ...baseCreate, resourceType: 'workflow' }) + + expect(dbChainMockFns.values).toHaveBeenCalledWith(expect.objectContaining({ sortOrder: -3 })) + }) + + it('starts at zero when the folder is the first thing in its location', async () => { + queueTableRows(schemaMock.folder, [{ minSortOrder: null }]) + dbChainMockFns.returning.mockResolvedValueOnce([folderRow()]) + + await createFolder(baseCreate) + + expect(dbChainMockFns.values).toHaveBeenCalledWith(expect.objectContaining({ sortOrder: 0 })) + }) + + it('honours an explicit sortOrder without querying siblings', async () => { + dbChainMockFns.returning.mockResolvedValueOnce([folderRow({ sortOrder: 42 })]) + + await createFolder({ ...baseCreate, sortOrder: 42 }) + + expect(dbChainMockFns.values).toHaveBeenCalledWith(expect.objectContaining({ sortOrder: 42 })) + expect(dbChainMockFns.select).not.toHaveBeenCalled() + }) + + it('returns a conflict on a duplicate sibling name instead of silently renaming', async () => { + // Create is a path where the user chose the name, so the active-name unique index must + // surface as a 409 they can act on — deduplicating here would hand back a folder the + // user never asked for. + queueTableRows(schemaMock.folder, [{ minSortOrder: 0 }]) + dbChainMockFns.returning.mockRejectedValueOnce(uniqueViolation()) + + const result = await createFolder(baseCreate) + + expect(result).toEqual({ + success: false, + error: DUPLICATE_NAME_ERROR, + errorCode: 'conflict', + }) + }) + + it('reports any other insert failure as internal rather than a name conflict', async () => { + queueTableRows(schemaMock.folder, [{ minSortOrder: 0 }]) + dbChainMockFns.returning.mockRejectedValueOnce(new Error('connection reset')) + + const result = await createFolder(baseCreate) + + expect(result).toEqual({ + success: false, + error: 'Internal server error', + errorCode: 'internal', + }) + }) +}) + +describe('updateFolder', () => { + const baseUpdate = { + resourceType: 'table' as const, + folderId: 'folder-1', + workspaceId: 'ws-1', + userId: 'user-1', + } + + it('writes only the fields the caller supplied, plus a fresh updatedAt', async () => { + dbChainMockFns.returning.mockResolvedValueOnce([folderRow({ name: 'Renamed' })]) + + const result = await updateFolder({ ...baseUpdate, name: ' Renamed ' }) + + expect(result.success).toBe(true) + const [set] = dbChainMockFns.set.mock.calls[0] as [Record] + expect(set.name).toBe('Renamed') + expect(set.updatedAt).toBeInstanceOf(Date) + expect(set).not.toHaveProperty('parentId') + expect(set).not.toHaveProperty('sortOrder') + }) + + it('reparents a folder under a validated parent', async () => { + queueTableRows(schemaMock.folder, [{ workspaceId: 'ws-1', archivedAt: null }]) + dbChainMockFns.returning.mockResolvedValueOnce([folderRow({ parentId: 'parent-1' })]) + + const result = await updateFolder({ ...baseUpdate, parentId: 'parent-1' }) + + expect(result.success).toBe(true) + const [set] = dbChainMockFns.set.mock.calls[0] as [Record] + expect(set.parentId).toBe('parent-1') + }) + + it('clears the parent when reparenting to the root', async () => { + dbChainMockFns.returning.mockResolvedValueOnce([folderRow()]) + + await updateFolder({ ...baseUpdate, parentId: null }) + + const [set] = dbChainMockFns.set.mock.calls[0] as [Record] + expect(set.parentId).toBeNull() + }) + + it('drops a locked flag on a resource type without lock semantics', async () => { + // The engine is reachable from the copilot tools as well as the route, so a `locked` + // value on an unlockable tree must never reach the row. + dbChainMockFns.returning.mockResolvedValueOnce([folderRow()]) + + await updateFolder({ ...baseUpdate, locked: true }) + + const [set] = dbChainMockFns.set.mock.calls[0] as [Record] + expect(set).not.toHaveProperty('locked') + }) + + it('persists a locked flag on the one resource type that supports locking', async () => { + setConfig({ resourceType: 'workflow', countKey: 'workflows', supportsLocking: true }) + dbChainMockFns.returning.mockResolvedValueOnce([folderRow({ locked: true })]) + + await updateFolder({ ...baseUpdate, resourceType: 'workflow', locked: true }) + + const [set] = dbChainMockFns.set.mock.calls[0] as [Record] + expect(set.locked).toBe(true) + }) + + it('refuses a reparent that would close a cycle', async () => { + // A folder moved under its own descendant disappears from every tree walk, taking its + // whole subtree with it. + queueTableRows(schemaMock.folder, [{ workspaceId: 'ws-1', archivedAt: null }]) + mockWouldCreateFolderCycle.mockResolvedValueOnce(true) + + const result = await updateFolder({ ...baseUpdate, parentId: 'descendant-1' }) + + expect(result).toEqual({ + success: false, + error: 'Cannot create circular folder reference', + errorCode: 'validation', + }) + expect(dbChainMockFns.set).not.toHaveBeenCalled() + }) + + it('refuses a folder that names itself as its own parent', async () => { + const result = await updateFolder({ ...baseUpdate, parentId: 'folder-1' }) + + expect(result).toEqual({ + success: false, + error: 'Folder cannot be its own parent', + errorCode: 'validation', + }) + expect(mockWouldCreateFolderCycle).not.toHaveBeenCalled() + }) + + it('reports a folder outside this workspace or resourceType as not found', async () => { + dbChainMockFns.returning.mockResolvedValueOnce([]) + + const result = await updateFolder({ ...baseUpdate, name: 'Renamed' }) + + expect(result).toEqual({ + success: false, + error: 'Folder not found', + errorCode: 'not_found', + }) + }) + + it('returns a conflict when a rename collides with an active sibling', async () => { + // Rename, like create, is a name the user chose — a 409 lets them pick another rather + // than being handed "Reports (1)". + dbChainMockFns.returning.mockRejectedValueOnce(uniqueViolation()) + + const result = await updateFolder({ ...baseUpdate, name: 'Taken' }) + + expect(result).toEqual({ + success: false, + error: DUPLICATE_NAME_ERROR, + errorCode: 'conflict', + }) + }) +}) + +describe('deleteFolder', () => { + const baseDelete = { + resourceType: 'table' as const, + folderId: 'folder-1', + workspaceId: 'ws-1', + userId: 'user-1', + folderName: 'Reports', + } + + it('reports a folder outside this workspace or resourceType as not found', async () => { + queueTableRows(schemaMock.folder, []) + + const result = await deleteFolder(baseDelete) + + expect(result).toEqual({ + success: false, + error: 'Folder not found', + errorCode: 'not_found', + }) + expect(mockArchiveFolderCascade).not.toHaveBeenCalled() + }) + + it('archives the resolved subtree and reports counts under the resource’s own key', async () => { + queueTableRows(schemaMock.folder, [{ deletedAt: null }]) + mockCollectCascadeSubtreeIds.mockResolvedValueOnce(['folder-1', 'sub-1']) + mockArchiveFolderCascade.mockResolvedValueOnce({ folders: 2, children: 3 }) + + const result = await deleteFolder(baseDelete) + + expect(result).toEqual({ success: true, deletedItems: { folders: 2, tables: 3 } }) + expect(mockArchiveFolderCascade).toHaveBeenCalledWith( + dbChainMock.db, + resourceConfig.current, + 'ws-1', + ['folder-1', 'sub-1'], + expect.any(Date) + ) + }) + + it('refuses the delete whole when the resource’s guard rejects it', async () => { + // Tables refuse deletion while delete-locked; deleting the folder around one must not + // become a way around that control, and must not archive half the subtree first. + setConfig({ guardDelete: mockGuardDelete }) + mockGuardDelete.mockResolvedValueOnce({ + error: 'Cannot delete folder: table Ledger is delete-locked', + errorCode: 'locked', + }) + queueTableRows(schemaMock.folder, [{ deletedAt: null }]) + + const result = await deleteFolder(baseDelete) + + expect(result).toEqual({ + success: false, + error: 'Cannot delete folder: table Ledger is delete-locked', + errorCode: 'locked', + }) + expect(mockArchiveFolderCascade).not.toHaveBeenCalled() + }) + + it('hands the guard the whole resolved subtree, not just the root', async () => { + setConfig({ guardDelete: mockGuardDelete }) + mockGuardDelete.mockResolvedValueOnce(null) + mockCollectCascadeSubtreeIds.mockResolvedValueOnce(['folder-1', 'sub-1']) + queueTableRows(schemaMock.folder, [{ deletedAt: null }]) + + await deleteFolder(baseDelete) + + expect(mockGuardDelete).toHaveBeenCalledWith({ + workspaceId: 'ws-1', + folderIds: ['folder-1', 'sub-1'], + }) + }) + + it('reuses an already-archived folder’s timestamp so a retry rejoins the same snapshot', async () => { + // A fresh stamp would be unrecoverable: the folder row keeps its original deletedAt, so + // anything archived under the new stamp would never match on restore. + queueTableRows(schemaMock.folder, [{ deletedAt: ARCHIVED_AT }]) + + await deleteFolder(baseDelete) + + expect(mockCollectCascadeSubtreeIds).toHaveBeenCalledWith( + dbChainMock.db, + 'ws-1', + 'table', + 'folder-1', + ARCHIVED_AT + ) + expect(mockArchiveFolderCascade).toHaveBeenCalledWith( + dbChainMock.db, + resourceConfig.current, + 'ws-1', + ['folder-1'], + ARCHIVED_AT + ) + }) + + it('resolves the timestamp before the subtree walk, which needs it to see stragglers', async () => { + queueTableRows(schemaMock.folder, [{ deletedAt: ARCHIVED_AT }]) + + await deleteFolder(baseDelete) + + const [, , , , walkTimestamp] = mockCollectCascadeSubtreeIds.mock.calls[0] + const [, , , , archiveTimestamp] = mockArchiveFolderCascade.mock.calls[0] + expect(walkTimestamp).toBe(archiveTimestamp) + }) +}) + +describe('restoreFolder', () => { + const baseRestore = { + resourceType: 'table' as const, + folderId: 'folder-1', + workspaceId: 'ws-1', + userId: 'user-1', + } + + it('reports a folder outside this workspace or resourceType as not found', async () => { + queueTableRows(schemaMock.folder, []) + + const result = await restoreFolder(baseRestore) + + expect(result).toEqual({ + success: false, + error: 'Folder not found', + errorCode: 'not_found', + }) + }) + + it('is a no-op on a folder that was never archived', async () => { + queueTableRows(schemaMock.folder, [folderRow({ deletedAt: null })]) + + const result = await restoreFolder(baseRestore) + + expect(result).toEqual({ success: true, restoredItems: { folders: 0, tables: 0 } }) + expect(mockRestoreFolderRows).not.toHaveBeenCalled() + }) + + it('refuses to restore into an archived workspace', async () => { + queueTableRows(schemaMock.folder, [folderRow({ deletedAt: ARCHIVED_AT })]) + mockGetWorkspaceWithOwner.mockResolvedValueOnce({ id: 'ws-1', archivedAt: ARCHIVED_AT }) + + const result = await restoreFolder(baseRestore) + + expect(result).toEqual({ + success: false, + error: 'Cannot restore folder into an archived workspace', + errorCode: 'validation', + }) + expect(mockRestoreFolderRows).not.toHaveBeenCalled() + }) + + it('re-roots a folder whose original parent is still archived', async () => { + // Restoring it under an archived parent would revive a row that no tree walk reaches — + // visible in no list, restorable by nothing. + queueTableRows(schemaMock.folder, [folderRow({ deletedAt: ARCHIVED_AT, parentId: 'parent-1' })]) + queueTableRows(schemaMock.folder, [{ archivedAt: ARCHIVED_AT }]) + + const result = await restoreFolder(baseRestore) + + expect(result.success).toBe(true) + expect(dbChainMockFns.set).toHaveBeenCalledWith({ parentId: null }) + // The name must then be checked against the ROOT's siblings, not the old parent's. + expect(mockDeduplicateFolderName).toHaveBeenCalledWith( + dbChainMock.db, + 'ws-1', + null, + 'Reports', + 'table' + ) + }) + + it('keeps a folder under its parent when that parent came back first', async () => { + queueTableRows(schemaMock.folder, [folderRow({ deletedAt: ARCHIVED_AT, parentId: 'parent-1' })]) + queueTableRows(schemaMock.folder, [{ archivedAt: null }]) + + await restoreFolder(baseRestore) + + expect(dbChainMockFns.set).not.toHaveBeenCalledWith({ parentId: null }) + expect(mockDeduplicateFolderName).toHaveBeenCalledWith( + dbChainMock.db, + 'ws-1', + 'parent-1', + 'Reports', + 'table' + ) + }) + + it('renames rather than 409s when the folder’s name was taken while it was gone', async () => { + // The caller cannot rename an archived folder, so a taken name would otherwise make it + // permanently unrestorable. + queueTableRows(schemaMock.folder, [folderRow({ deletedAt: ARCHIVED_AT })]) + mockDeduplicateFolderName.mockResolvedValueOnce('Reports (1)') + + const result = await restoreFolder(baseRestore) + + expect(result.success).toBe(true) + expect(dbChainMockFns.set).toHaveBeenCalledWith({ name: 'Reports (1)' }) + }) + + it('leaves the name alone when nothing took it', async () => { + queueTableRows(schemaMock.folder, [folderRow({ deletedAt: ARCHIVED_AT })]) + + await restoreFolder(baseRestore) + + expect(dbChainMockFns.set).not.toHaveBeenCalledWith( + expect.objectContaining({ name: expect.anything() }) + ) + }) + + it('restores only the rows archived under the folder’s own timestamp', async () => { + queueTableRows(schemaMock.folder, [folderRow({ deletedAt: ARCHIVED_AT })]) + mockCollectArchivedSubtreeIds.mockResolvedValueOnce(['folder-1', 'sub-1']) + mockRestoreFolderRows.mockResolvedValueOnce(2) + mockRestoreFolderChildren.mockResolvedValueOnce(4) + + const result = await restoreFolder(baseRestore) + + expect(result).toEqual({ success: true, restoredItems: { folders: 2, tables: 4 } }) + expect(mockCollectArchivedSubtreeIds).toHaveBeenCalledWith( + dbChainMock.db, + 'ws-1', + 'table', + 'folder-1', + ARCHIVED_AT + ) + expect(mockRestoreFolderRows).toHaveBeenCalledWith( + dbChainMock.db, + resourceConfig.current, + 'ws-1', + ['folder-1', 'sub-1'], + ARCHIVED_AT, + expect.any(Date) + ) + }) + + it('runs a restoreChildren hook before the folder rows come back', async () => { + // The hook opens its own transactions, so it cannot run nested — and going first is what + // keeps a partial failure retryable: the folder stays archived until the children are back. + setConfig({ restoreChildren: mockRestoreChildren }) + mockRestoreChildren.mockResolvedValueOnce(5) + queueTableRows(schemaMock.folder, [folderRow({ deletedAt: ARCHIVED_AT })]) + + const result = await restoreFolder(baseRestore) + + expect(mockRestoreChildren).toHaveBeenCalledWith({ + workspaceId: 'ws-1', + folderIds: ['folder-1'], + timestamp: ARCHIVED_AT, + }) + expect(mockRestoreChildren.mock.invocationCallOrder[0]).toBeLessThan( + mockRestoreFolderRows.mock.invocationCallOrder[0] + ) + // The hook's count wins; the generic child restore never runs for this resource. + expect(mockRestoreFolderChildren).not.toHaveBeenCalled() + expect(result).toEqual({ success: true, restoredItems: { folders: 1, tables: 5 } }) + }) + + it('returns a conflict when a concurrent create takes the name after the dedup check', async () => { + // Dedup covers the restore root, but clearing deletedAt brings the row back under the + // active-name unique index and that window is real. + queueTableRows(schemaMock.folder, [folderRow({ deletedAt: ARCHIVED_AT })]) + mockRestoreFolderRows.mockRejectedValueOnce(uniqueViolation()) + + const result = await restoreFolder(baseRestore) + + expect(result).toEqual({ + success: false, + error: DUPLICATE_NAME_ERROR, + errorCode: 'conflict', + }) + }) + + it('rethrows any non-conflict failure instead of reporting a name collision', async () => { + queueTableRows(schemaMock.folder, [folderRow({ deletedAt: ARCHIVED_AT })]) + mockRestoreFolderRows.mockRejectedValueOnce(new Error('connection reset')) + + await expect(restoreFolder(baseRestore)).rejects.toThrow('connection reset') + }) +}) diff --git a/apps/sim/lib/folders/lifecycle.ts b/apps/sim/lib/folders/lifecycle.ts index 526e4eaff5a..ea1657dfd06 100644 --- a/apps/sim/lib/folders/lifecycle.ts +++ b/apps/sim/lib/folders/lifecycle.ts @@ -6,6 +6,7 @@ import { getPostgresErrorCode } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { and, eq, isNull, min } from 'drizzle-orm' import type { FolderCascadeCountsApi, FolderResourceType } from '@/lib/api/contracts/folders' +import type { DbOrTx } from '@/lib/db/types' import { archiveFolderCascade, collectArchivedSubtreeIds, @@ -116,17 +117,18 @@ async function assertParentFolderInWorkspace( * their folders, so their `sortOrder` participates; knowledge bases and tables have no * per-row ordering and are ignored via the absent `sortOrderColumn`. */ -async function nextFolderSortOrder( +export async function nextFolderSortOrder( resourceType: FolderResourceType, workspaceId: string, - parentId: string | null | undefined + parentId: string | null | undefined, + tx: DbOrTx = db ): Promise { const config = folderResourceConfig(resourceType) const folderParentCondition = parentId ? eq(folderTable.parentId, parentId) : isNull(folderTable.parentId) - const folderMinPromise = db + const folderMinPromise = tx .select({ minSortOrder: min(folderTable.sortOrder) }) .from(folderTable) .where( @@ -138,7 +140,7 @@ async function nextFolderSortOrder( ) const childMinPromise: Promise> = config.sortOrderColumn - ? db + ? tx .select({ minSortOrder: min(config.sortOrderColumn) }) .from(config.table) .where( @@ -274,6 +276,13 @@ export async function updateFolder(params: UpdateFolderParams): Promise +): Promise { + if (!folderId || !workspaceId) return null + if (restoringFolderIds?.has(folderId)) return folderId + return (await findActiveFolder(folderId, workspaceId, resourceType)) ? folderId : null +} + /** Shared by `GET /api/folders` and the sidebar prefetch so the query never drifts between them. */ export async function listFoldersForWorkspace( workspaceId: string, diff --git a/apps/sim/lib/folders/tree.ts b/apps/sim/lib/folders/tree.ts index 41196a7e35b..dec8e50a879 100644 --- a/apps/sim/lib/folders/tree.ts +++ b/apps/sim/lib/folders/tree.ts @@ -1,9 +1,5 @@ import type { FolderTreeNode, WorkflowFolder } from '@/stores/folders/types' -export function buildFolderMap(folders: WorkflowFolder[]): Record { - return Object.fromEntries(folders.map((folder) => [folder.id, folder])) -} - export function buildFolderTree( folders: Record, workspaceId: string @@ -42,14 +38,26 @@ export function getChildFolders( .sort((a, b) => a.sortOrder - b.sortOrder || a.name.localeCompare(b.name)) } +/** + * Root-first ancestor chain of `folderId`, as folder objects — callers need the ids (cycle + * checks, expanding each ancestor), not just the names, which is why this is distinct from + * the string-returning `getFolderPath` in `@/hooks/queries/utils/folder-tree`. + * + * `visited` is not defensive padding. The client folder map is written optimistically by + * `useReorderFolders`, which sets `parentId` without validating the result, so a cycle is + * reachable in cache even though the server rejects one. Without the guard this loops + * forever and hangs the tab. + */ export function getFolderPath( folders: Record, folderId: string ): WorkflowFolder[] { const path: WorkflowFolder[] = [] + const visited = new Set() let currentId: string | null = folderId - while (currentId && folders[currentId]) { + while (currentId && folders[currentId] && !visited.has(currentId)) { + visited.add(currentId) const folder: WorkflowFolder = folders[currentId] path.unshift(folder) currentId = folder.parentId diff --git a/apps/sim/lib/knowledge/service.ts b/apps/sim/lib/knowledge/service.ts index d2e18f12e4f..cbac1283b63 100644 --- a/apps/sim/lib/knowledge/service.ts +++ b/apps/sim/lib/knowledge/service.ts @@ -21,7 +21,7 @@ import { type StorageBillingContext, } from '@/lib/billing/storage' import { generateRestoreName } from '@/lib/core/utils/restore-name' -import { findActiveFolder } from '@/lib/folders/queries' +import { findActiveFolder, resolveRestoredFolderId } from '@/lib/folders/queries' import type { ChunkingConfig, CreateKnowledgeBaseData, @@ -793,7 +793,8 @@ export async function deleteKnowledgeBase( */ export async function restoreKnowledgeBase( knowledgeBaseId: string, - requestId: string + requestId: string, + options?: { restoringFolderIds?: ReadonlySet } ): Promise { const [kb] = await db .select({ @@ -827,13 +828,15 @@ export async function restoreKnowledgeBase( * Restoring a knowledge base whose folder is still archived would file it under a folder * the Knowledge page never renders, leaving an active row nobody can reach. Re-root it * instead — the same treatment `restoreFolder` gives a folder with an archived parent. + * `restoringFolderIds` exempts the folder subtree this restore is part of, which is still + * archived at the moment the cascade calls in. */ - const restoredFolderId = - kb.folderId && kb.workspaceId - ? (await findActiveFolder(kb.folderId, kb.workspaceId, 'knowledge_base')) - ? kb.folderId - : null - : null + const restoredFolderId = await resolveRestoredFolderId( + kb.folderId, + kb.workspaceId, + 'knowledge_base', + options?.restoringFolderIds + ) /** * A concurrent create/rename can commit the same active name after `generateRestoreName`'s check diff --git a/apps/sim/lib/resources/orchestration/restore-resource.ts b/apps/sim/lib/resources/orchestration/restore-resource.ts index de305bdd606..81867fdfcd4 100644 --- a/apps/sim/lib/resources/orchestration/restore-resource.ts +++ b/apps/sim/lib/resources/orchestration/restore-resource.ts @@ -1,6 +1,7 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' +import type { FolderResourceType } from '@/lib/api/contracts/folders' import type { MothershipResource } from '@/lib/copilot/resources/types' import type { ToolExecutionResult } from '@/lib/copilot/tool-executor/types' import { @@ -27,6 +28,21 @@ export type RestorableResourceType = | 'knowledgebase' | 'folder' | 'file_folder' + | 'knowledge_folder' + | 'table_folder' + +/** + * Folder trees the generic engine restores, keyed by the restorable type that names them. + * `'folder'` stays the workflow tree so the existing tool contract does not shift meaning; + * the other trees get their own names, mirroring how `file_folder` already sits beside it. + */ +const FOLDER_RESOURCE_TYPE_BY_RESTORABLE: Partial< + Record +> = { + folder: 'workflow', + knowledge_folder: 'knowledge_base', + table_folder: 'table', +} export interface PerformRestoreResourceParams { type: RestorableResourceType @@ -132,17 +148,24 @@ export async function performRestoreResource( ]) } - case 'folder': { + case 'folder': + case 'knowledge_folder': + case 'table_folder': { if (!(await hasWriteAccess(userId, workspaceId, workspaceId))) { return { success: false, error: 'Folder not found' } } - const result = await performRestoreFolder({ folderId: id, workspaceId, userId }) + const result = await performRestoreFolder({ + folderId: id, + workspaceId, + userId, + resourceType: FOLDER_RESOURCE_TYPE_BY_RESTORABLE[type], + }) if (!result.success) { return { success: false, error: result.error || 'Failed to restore folder' } } - logger.info('Folder restored via restore_resource', { folderId: id }) + logger.info('Folder restored via restore_resource', { folderId: id, type }) return success({ type, id, restoredItems: result.restoredItems }) } diff --git a/apps/sim/lib/table/service.ts b/apps/sim/lib/table/service.ts index defac61d270..12ad8364f7d 100644 --- a/apps/sim/lib/table/service.ts +++ b/apps/sim/lib/table/service.ts @@ -16,6 +16,7 @@ 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 { resolveRestoredFolderId } from '@/lib/folders/queries' 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' @@ -887,7 +888,11 @@ export async function deleteTable( * archived by a bypass path (e.g. workspace archive, which has no un-archive), * making the lock the thing that permanently loses the data it protects. */ -export async function restoreTable(tableId: string, requestId: string): Promise { +export async function restoreTable( + tableId: string, + requestId: string, + options?: { restoringFolderIds?: ReadonlySet } +): Promise { const table = await getTableById(tableId, { includeArchived: true }) if (!table) { throw new Error('Table not found') @@ -905,6 +910,20 @@ export async function restoreTable(tableId: string, requestId: string): Promise< } } + /** + * Restoring a table whose folder is still archived would file it under a folder the Tables + * page never renders, leaving an active row nobody can reach. Re-root it instead — the same + * treatment `restoreFolder` gives a folder with an archived parent. `restoringFolderIds` + * exempts the folder subtree this restore is part of, which is still archived at the moment + * the cascade calls in. + */ + const restoredFolderId = await resolveRestoredFolderId( + table.folderId, + table.workspaceId, + 'table', + options?.restoringFolderIds + ) + /** * A concurrent rename/create can claim the chosen name after `generateRestoreName`'s check (MVCC). * Retries pick a new random suffix; 23505 maps to {@link TableConflictError} after exhaustion. @@ -937,7 +956,12 @@ export async function restoreTable(tableId: string, requestId: string): Promise< const now = new Date() await tx .update(userTableDefinitions) - .set({ archivedAt: null, updatedAt: now, name: attemptedRestoreName }) + .set({ + archivedAt: null, + updatedAt: now, + name: attemptedRestoreName, + folderId: restoredFolderId, + }) .where(eq(userTableDefinitions.id, tableId)) }) break diff --git a/apps/sim/lib/workflows/lifecycle.ts b/apps/sim/lib/workflows/lifecycle.ts index ee605fe0e8e..f7b63786284 100644 --- a/apps/sim/lib/workflows/lifecycle.ts +++ b/apps/sim/lib/workflows/lifecycle.ts @@ -196,6 +196,12 @@ export async function archiveWorkflow( interface RestoreWorkflowOptions { requestId: string + /** + * Folder ids being restored alongside this workflow. A folder cascade restores its + * children BEFORE it un-archives the folder rows, so without this the re-root check below + * sees the folder still archived and dumps every workflow at the workspace root. + */ + restoringFolderIds?: ReadonlySet } export async function restoreWorkflow( @@ -221,7 +227,7 @@ export async function restoreWorkflow( } let clearFolderId = false - if (existingWorkflow.folderId) { + if (existingWorkflow.folderId && !options.restoringFolderIds?.has(existingWorkflow.folderId)) { const [folder] = await db .select({ archivedAt: folderTable.deletedAt }) .from(folderTable) diff --git a/apps/sim/lib/workflows/orchestration/folder-lifecycle.ts b/apps/sim/lib/workflows/orchestration/folder-lifecycle.ts index 78320b60ca6..e4903974aa4 100644 --- a/apps/sim/lib/workflows/orchestration/folder-lifecycle.ts +++ b/apps/sim/lib/workflows/orchestration/folder-lifecycle.ts @@ -1,4 +1,5 @@ import type { folder as folderTable } from '@sim/db/schema' +import type { FolderResourceType } from '@/lib/api/contracts/folders' import { createFolder, deleteFolder, restoreFolder, updateFolder } from '@/lib/folders/lifecycle' import type { FolderMutationErrorCode } from '@/lib/folders/status' import type { OrchestrationErrorCode } from '@/lib/workflows/orchestration/types' @@ -56,7 +57,14 @@ export interface PerformDeleteFolderResult { deletedItems?: { folders: number; workflows?: number } } -export interface PerformRestoreFolderParams extends PerformDeleteFolderParams {} +export interface PerformRestoreFolderParams extends PerformDeleteFolderParams { + /** + * Folder tree to restore into. Defaults to `'workflow'` so every existing caller — and the + * copilot tool contract — is unchanged; Recently Deleted and the restore tool pass the + * knowledge-base or table tree explicitly. + */ + resourceType?: FolderResourceType +} export interface PerformRestoreFolderResult { success: boolean @@ -86,5 +94,5 @@ export function performDeleteFolder( export function performRestoreFolder( params: PerformRestoreFolderParams ): Promise { - return restoreFolder({ ...params, resourceType: 'workflow' }) + return restoreFolder({ ...params, resourceType: params.resourceType ?? 'workflow' }) } diff --git a/apps/sim/lib/workspaces/naming.ts b/apps/sim/lib/workspaces/naming.ts index 5bb9f80a789..02284ca7093 100644 --- a/apps/sim/lib/workspaces/naming.ts +++ b/apps/sim/lib/workspaces/naming.ts @@ -114,7 +114,10 @@ export async function generateFolderName(workspaceId: string): Promise { /** * Generates the next subfolder name for a parent folder */ -async function generateSubfolderName(workspaceId: string, parentFolderId: string): Promise { +export async function generateSubfolderName( + workspaceId: string, + parentFolderId: string +): Promise { const folders = await fetchWorkspaceFolders(workspaceId) const subfolders = folders.filter((folder) => folder.parentId === parentFolderId) diff --git a/packages/db/migrations/0274_file_folder_cutover_catchup_backfill.sql b/packages/db/migrations/0274_file_folder_cutover_catchup_backfill.sql new file mode 100644 index 00000000000..37a1d2adbc3 --- /dev/null +++ b/packages/db/migrations/0274_file_folder_cutover_catchup_backfill.sql @@ -0,0 +1,31 @@ +-- Catch-up backfill: file folders created AFTER migration 0272 ran. +-- +-- 0272 seeded `folder` with `resource_type = 'file'` from `workspace_file_folders`, but its +-- backfill is guarded by `WHERE NOT EXISTS (SELECT 1 FROM folder WHERE resource_type = 'file')` +-- so it fires exactly once. Every file folder created between that migration and the cutover +-- in this PR was therefore written to `workspace_file_folders` only, and exists nowhere in +-- `folder`. +-- +-- The cutover repoints all reads and writes at `folder`. Without this statement those folders +-- disappear from the Files page on deploy, and the files inside them become unreachable — +-- their `folder_id` would point at a row nothing reads any more. +-- +-- Safe to run repeatedly and safe to run while the old code is still serving: it only INSERTs +-- rows that are missing, keyed by the source id (0272 preserved ids, so a folder is the same +-- row in both tables). Parents and children go in one statement, so the self-referencing FK — +-- checked as an AFTER-ROW trigger at end of statement — is satisfied regardless of row order. +-- +-- Name collisions cannot occur: every row comes from `workspace_file_folders`, whose own +-- active-unique index enforces exactly the uniqueness `folder`'s partial index requires, and +-- nothing other than these backfills ever writes `resource_type = 'file'`. The bare +-- ON CONFLICT DO NOTHING is belt-and-braces for a concurrent re-run. +INSERT INTO "folder" (id, resource_type, name, user_id, workspace_id, parent_id, locked, sort_order, created_at, updated_at, deleted_at) +SELECT + f.id, 'file', f.name, + f.user_id, f.workspace_id, f.parent_id, false, f.sort_order, + f.created_at, f.updated_at, f.deleted_at +FROM "workspace_file_folders" f +WHERE NOT EXISTS ( + SELECT 1 FROM "folder" existing WHERE existing.id = f.id +) +ON CONFLICT DO NOTHING; diff --git a/packages/db/migrations/meta/_journal.json b/packages/db/migrations/meta/_journal.json index f36d0c740c1..aad8eca6e30 100644 --- a/packages/db/migrations/meta/_journal.json +++ b/packages/db/migrations/meta/_journal.json @@ -1912,6 +1912,13 @@ "when": 1785281897201, "tag": "0273_copilot_tool_permission_decision", "breakpoints": true + }, + { + "idx": 274, + "version": "7", + "when": 1785281897202, + "tag": "0274_file_folder_cutover_catchup_backfill", + "breakpoints": true } ] } diff --git a/packages/db/schema.ts b/packages/db/schema.ts index 614157916f6..1504fe9067a 100644 --- a/packages/db/schema.ts +++ b/packages/db/schema.ts @@ -1902,9 +1902,20 @@ export const workspaceFile = pgTable( }) ) -// DEPRECATED: superseded by the generic `folder` table (resourceType='file'). Kept -// (unread, unwritten) until the generic-folders cutover is verified in production; dropped -// in a follow-up contract migration. +/** + * DEPRECATED: superseded by the generic `folder` table (`resource_type = 'file'`). + * + * As of the file-folder cutover the application no longer reads or writes this table — + * every former query site now targets `folder` scoped to `resource_type = 'file'`. It is + * retained as the rollback copy for that deploy, NOT because it is already unused history: + * before the cutover it was the live table, and migration 0272's one-shot backfill did not + * cover folders created after it ran (migration 0274 catches those up). + * + * Do NOT drop it in a contract migration until BOTH hold: the cutover has been running in + * production long enough that a rollback is off the table, and a row-count comparison + * against `folder WHERE resource_type = 'file'` confirms nothing was stranded. Dropping it + * on the strength of "no code references it" alone destroys the only rollback path. + */ export const workspaceFileFolder = pgTable( 'workspace_file_folders', { From d1d3b9953c4c994f278db51c5454d17a0a80eb72 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 28 Jul 2026 19:59:31 -0700 Subject: [PATCH 03/11] improvement(folders): show the pin glyph on Files rows and pin the deploy contract Files rows were left without the pin indicator the other two lists gained, so a pinned file or folder still sorted to the top of the Files page with nothing explaining why. Adds `lib/api/contracts/folders.test.ts`, which pins the three cases that together ARE the rolling-deploy contract: an omitted `resourceType` defaults to `workflow` (so a client predating the field keeps working), a present-but-unrecognized one is rejected rather than coerced, and every served tree is accepted. --- .../workspace/[workspaceId]/files/files.tsx | 4 ++- apps/sim/lib/api/contracts/folders.test.ts | 28 +++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) create mode 100644 apps/sim/lib/api/contracts/folders.test.ts diff --git a/apps/sim/app/workspace/[workspaceId]/files/files.tsx b/apps/sim/app/workspace/[workspaceId]/files/files.tsx index bde85ad6454..7536b67bf21 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/files.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/files.tsx @@ -505,6 +505,7 @@ export function Files() { name: { icon: , label: folder.name, + pinned: pinnedFolderIds.has(folder.id), }, size: { label: @@ -530,6 +531,7 @@ export function Files() { name: { icon: , label: file.name, + pinned: pinnedFileIds.has(file.id), }, size: { label: formatFileSize(file.size, { includeBytes: true }), @@ -547,7 +549,7 @@ export function Files() { }) return [...folderRows, ...fileRows] - }, [visibleFolders, filteredFiles, membersById, folderSizeMap]) + }, [visibleFolders, filteredFiles, membersById, folderSizeMap, pinnedFolderIds, pinnedFileIds]) const rows: ResourceRow[] = useMemo(() => { if (!listRename.editingId) return baseRows diff --git a/apps/sim/lib/api/contracts/folders.test.ts b/apps/sim/lib/api/contracts/folders.test.ts new file mode 100644 index 00000000000..05188549150 --- /dev/null +++ b/apps/sim/lib/api/contracts/folders.test.ts @@ -0,0 +1,28 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { servedFolderResourceTypeSchema } from '@/lib/api/contracts/folders' + +/** + * These three cases together ARE the rolling-deploy contract for the folder engine, so they + * are pinned rather than left implicit in `.default()`. + */ +describe('servedFolderResourceTypeSchema', () => { + it('defaults an omitted value to workflow, so a client that predates the field still works', () => { + expect(servedFolderResourceTypeSchema.parse(undefined)).toBe('workflow') + }) + + it('rejects a present-but-unrecognized value rather than coercing it to workflow', () => { + // Coercing would file a knowledge-base folder into the workflow tree, where the + // Knowledge page can never see it again. A 400 is the only safe answer. + expect(servedFolderResourceTypeSchema.safeParse('bogus').success).toBe(false) + expect(servedFolderResourceTypeSchema.safeParse('').success).toBe(false) + }) + + it('accepts every tree the engine serves', () => { + for (const value of ['workflow', 'file', 'knowledge_base', 'table'] as const) { + expect(servedFolderResourceTypeSchema.parse(value)).toBe(value) + } + }) +}) From 781a76825499bd1bf41372372641e1f012a28e36 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 28 Jul 2026 20:15:43 -0700 Subject: [PATCH 04/11] fix(folders): close the findings from the full-diff audit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migration 0274 reconciles instead of catching up - The deployed manager writes `workspace_file_folders` EXCLUSIVELY, so `folder`'s file rows are frozen at the 0272 snapshot: every rename, move, delete, and restore since then lives only in the legacy table. An insert-only catch-up left all of that diverged — renames reverted, deleted folders came back as active phantoms, and a stale-active row could hold the name a newer folder legitimately took - That last case was silent: the bare `ON CONFLICT DO NOTHING` swallows a NAME violation as readily as an id one, so the newer folder was discarded and every file inside it stranded. The conflict target is now `(id)`, so a name collision — which would mean the source violated its own unique index — fails loudly instead - Reconciles by parking mirrored names first, then upserting, so no transient state can collide with the partial unique index. Both statements are idempotent, and the header says what nothing else would: this has to be run again after the deploy drains, because old pods keep writing the legacy table until they are gone `/api/folders` no longer serves file folders - Adding `'file'` to `servedFolderResourceTypeSchema` opened a second writer over the same rows. It bypassed the `workspace_file_folders:${workspaceId}` advisory lock that makes the manager's check-then-write pairs atomic, and it accepted names containing `/`, `\`, `.` and `..`, which the file surface forbids because a folder name becomes a path segment — a folder named `a/b` is then unreachable by path forever, and breadcrumbs resolve it as two segments. The storage cutover never needed a second API, so there isn't one Conflicts that returned 500 - `v1/admin/workspaces/[id]/import` created its folder with an unserialized SELECT-then- INSERT, so two imports sharing a folder path failed the whole import. The name is server-chosen, so it now adopts whichever folder won, like `ensureWorkspaceFileFolderPath` - Folder duplicate never caught 23505. `newId` is client-supplied, so replaying a duplicate whose response was lost hit the primary key and returned 500 instead of a 409 the caller can act on Performance - `knowledgeKeys` moved to `hooks/queries/utils/knowledge-keys.ts`. Reading it from `hooks/queries/kb/knowledge` pulled a ~1000-line hook module — and the `@sim/emcn` barrel behind it — into `hooks/queries/folders`, which three server prefetches import. That is the same import edge the navigation-speed audit measured at 11.8MB per workspace route; `tableKeys` already lived in a keys-only module for exactly this reason Merge drop - Knowledge never got the chrome Tables did: its prefetch now fetches the folder tree alongside the bases, so the list does not paint ungrouped and a `?folderId=` deep link does not render an empty breadcrumb, and its loading fallback declares the "New folder" action so the header does not shift on hydration Comments corrected to match reality - The shared folder row and context menu claimed Files as a consumer; Files still builds its own row and routes folders through `FileRowContextMenu`, which the TSDoc now says - `schema.ts` prescribed a row COUNT comparison before dropping the legacy table. The divergence 0274 repairs is in row CONTENTS, which a count check passes straight through --- .../app/api/folders/[id]/duplicate/route.ts | 15 + .../v1/admin/workspaces/[id]/import/route.ts | 48 +- .../folders/folder-context-menu.tsx | 8 +- .../components/folders/folder-row.tsx | 6 +- .../resource-registry/resource-registry.tsx | 2 +- .../knowledge/hooks/use-knowledge-upload.ts | 2 +- .../[workspaceId]/knowledge/loading.tsx | 7 +- .../[workspaceId]/knowledge/prefetch.ts | 53 +- .../[workspaceId]/lib/prefetch.test.ts | 2 +- .../knowledge-base-selector.tsx | 3 +- .../kb/use-knowledge-base-tag-definitions.ts | 3 +- apps/sim/hooks/kb/use-knowledge.ts | 2 +- apps/sim/hooks/kb/use-tag-definitions.ts | 2 +- apps/sim/hooks/queries/folders.ts | 8 +- apps/sim/hooks/queries/kb/connectors.ts | 2 +- apps/sim/hooks/queries/kb/knowledge.ts | 28 +- .../sim/hooks/queries/utils/knowledge-keys.ts | 37 + apps/sim/lib/api/contracts/folders.test.ts | 9 +- apps/sim/lib/api/contracts/folders.ts | 18 +- apps/sim/lib/cleanup/batch-delete.ts | 13 +- ...4_file_folder_cutover_catchup_backfill.sql | 31 - .../0274_file_folder_cutover_reconcile.sql | 63 + .../db/migrations/meta/0274_snapshot.json | 18236 ++++++++++++++++ packages/db/migrations/meta/_journal.json | 2 +- packages/db/schema.ts | 8 +- 25 files changed, 18489 insertions(+), 119 deletions(-) create mode 100644 apps/sim/hooks/queries/utils/knowledge-keys.ts delete mode 100644 packages/db/migrations/0274_file_folder_cutover_catchup_backfill.sql create mode 100644 packages/db/migrations/0274_file_folder_cutover_reconcile.sql create mode 100644 packages/db/migrations/meta/0274_snapshot.json diff --git a/apps/sim/app/api/folders/[id]/duplicate/route.ts b/apps/sim/app/api/folders/[id]/duplicate/route.ts index 1dadf899014..aaaabfdc31e 100644 --- a/apps/sim/app/api/folders/[id]/duplicate/route.ts +++ b/apps/sim/app/api/folders/[id]/duplicate/route.ts @@ -3,6 +3,7 @@ import { db } from '@sim/db' import { folder as folderTable, workflow } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { FolderLockedError } from '@sim/platform-authz/workflow' +import { getPostgresErrorCode } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { and, eq, isNull } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' @@ -200,6 +201,20 @@ export const POST = withRouteHandler( return NextResponse.json({ error: error.message }, { status: error.status }) } + /** + * `newId` is client-supplied, so replaying a duplicate whose response was lost hits the + * primary key rather than the name index. A name collision is equally reachable: dedup + * runs before the INSERT, but a concurrent create can take the name in between. Either + * way this is a conflict the caller can act on, not a server fault. + */ + if (getPostgresErrorCode(error) === '23505') { + logger.warn(`[${requestId}] Folder duplication conflicted`, { sourceFolderId }) + return NextResponse.json( + { error: 'A folder with this name already exists in this location' }, + { status: 409 } + ) + } + if (error instanceof FolderDuplicationError) { logger.warn(`[${requestId}] Folder duplication rejected: ${error.message}`, { sourceFolderId, 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 378aa8acedc..88768aa735c 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 @@ -26,7 +26,7 @@ import { db } from '@sim/db' import { folder as folderTable, workflow } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' +import { getErrorMessage, getPostgresErrorCode } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { and, eq, isNull } from 'drizzle-orm' import { NextResponse } from 'next/server' @@ -108,16 +108,42 @@ async function ensureImportFolder( if (existing) return existing.id const folderId = generateId() - await db.insert(folderTable).values({ - id: folderId, - resourceType: 'workflow', - name, - userId, - workspaceId, - parentId, - createdAt: new Date(), - updatedAt: new Date(), - }) + try { + await db.insert(folderTable).values({ + id: folderId, + resourceType: 'workflow', + name, + userId, + workspaceId, + parentId, + createdAt: new Date(), + updatedAt: new Date(), + }) + } catch (error) { + /** + * The SELECT above is not serialized against this INSERT, so two imports sharing a folder + * path race here. The name is server-chosen, not user-chosen, so the right resolution is + * to adopt whichever folder won rather than fail the whole import with a 500 — the same + * reuse-on-conflict `ensureWorkspaceFileFolderPath` already does. + */ + if (getPostgresErrorCode(error) !== '23505') throw error + + const [concurrent] = await db + .select({ id: folderTable.id }) + .from(folderTable) + .where( + and( + eq(folderTable.workspaceId, workspaceId), + eq(folderTable.resourceType, 'workflow'), + eq(folderTable.name, name), + parentId ? eq(folderTable.parentId, parentId) : isNull(folderTable.parentId), + isNull(folderTable.deletedAt) + ) + ) + .limit(1) + if (!concurrent) throw error + return concurrent.id + } return folderId } diff --git a/apps/sim/app/workspace/[workspaceId]/components/folders/folder-context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/components/folders/folder-context-menu.tsx index 0f4785f915b..614786a1ed3 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/folders/folder-context-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/folders/folder-context-menu.tsx @@ -32,8 +32,12 @@ interface FolderContextMenuProps { } /** - * Row context menu for a folder, shared across every foldered resource list so a folder - * offers the same actions on Knowledge, Tables, and Files. + * Row context menu for a folder, shared by the resource lists built on the generic folder + * engine — Knowledge and Tables — so a folder offers the same actions on both. + * + * Files is deliberately not a consumer: its rows carry multi-select and bulk actions, so a + * folder there routes through `FileRowContextMenu` alongside the file rows it is selected + * with. Converging the two is follow-up work. * * Mirrors the resource-row menus (`KnowledgeBaseContextMenu`, `FileRowContextMenu`): a * `DropdownMenu` anchored to a one-pixel fixed trigger at the cursor, non-modal so the list diff --git a/apps/sim/app/workspace/[workspaceId]/components/folders/folder-row.tsx b/apps/sim/app/workspace/[workspaceId]/components/folders/folder-row.tsx index 10b9bf856b1..34ab496419e 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/folders/folder-row.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/folders/folder-row.tsx @@ -27,8 +27,10 @@ export interface FolderRowOptions { } /** - * Builds the canonical folder `ResourceRow` shared by every foldered list, so a folder looks - * and behaves identically on Knowledge, Tables, and Files. The row id is namespaced by + * Builds the canonical folder `ResourceRow` for the lists built on the generic folder engine + * (Knowledge and Tables), so a folder looks and behaves identically on both. Files still + * builds its own row — it carries a size roll-up and a distinct row-id scheme that also + * namespaces file ids. The row id here is namespaced by * {@link folderRowId}, so the caller's click/context-menu handlers distinguish a folder from * a resource with `parseFolderedRowId` rather than a second lookup. * 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 95335453673..1681f3a07b8 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 @@ -22,12 +22,12 @@ import type { MothershipResourceType, } from '@/app/workspace/[workspaceId]/home/types' import { getBareIconStyle, type StyleableIcon } from '@/blocks/icon-color' -import { knowledgeKeys } from '@/hooks/queries/kb/knowledge' import { logKeys } from '@/hooks/queries/logs' import { mothershipChatKeys } from '@/hooks/queries/mothership-chats' import { scheduleKeys } from '@/hooks/queries/schedules' import { folderKeys } from '@/hooks/queries/utils/folder-keys' import { invalidateWorkflowLists } from '@/hooks/queries/utils/invalidate-workflow-lists' +import { knowledgeKeys } from '@/hooks/queries/utils/knowledge-keys' import { tableKeys } from '@/hooks/queries/utils/table-keys' import { workspaceFileFolderKeys } from '@/hooks/queries/workspace-file-folders' import { workspaceFilesKeys } from '@/hooks/queries/workspace-files' diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/hooks/use-knowledge-upload.ts b/apps/sim/app/workspace/[workspaceId]/knowledge/hooks/use-knowledge-upload.ts index 91c7d969a35..3d096f53535 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/hooks/use-knowledge-upload.ts +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/hooks/use-knowledge-upload.ts @@ -19,7 +19,7 @@ import { WHOLE_FILE_PARALLEL_UPLOADS, } from '@/lib/uploads/client/direct-upload' import { getFileContentType, isAbortError, isNetworkError } from '@/lib/uploads/utils/file-utils' -import { knowledgeKeys } from '@/hooks/queries/kb/knowledge' +import { knowledgeKeys } from '@/hooks/queries/utils/knowledge-keys' const logger = createLogger('KnowledgeUpload') diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/loading.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/loading.tsx index 0f788bda4e6..ace6802d984 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/loading.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/loading.tsx @@ -1,7 +1,7 @@ 'use client' import { Plus } from '@sim/emcn' -import { Database } from '@sim/emcn/icons' +import { Database, FolderPlus } from '@sim/emcn/icons' import { type ChromeActionSpec, ResourceChromeFallback, @@ -17,7 +17,10 @@ const COLUMNS = [ { id: 'updated', header: 'Last Updated' }, ] -const ACTIONS: ChromeActionSpec[] = [{ text: 'New base', icon: Plus, variant: 'primary' }] +const ACTIONS: ChromeActionSpec[] = [ + { text: 'New folder', icon: FolderPlus }, + { text: 'New base', icon: Plus, variant: 'primary' }, +] export default function KnowledgeLoading() { return ( diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/prefetch.ts b/apps/sim/app/workspace/[workspaceId]/knowledge/prefetch.ts index 11b61bb07b9..1f5d60f3d94 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/prefetch.ts +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/prefetch.ts @@ -1,28 +1,49 @@ import type { QueryClient } from '@tanstack/react-query' +import type { FolderApi } from '@/lib/api/contracts/folders' import type { KnowledgeBaseData } from '@/lib/api/contracts/knowledge' import { prefetchInternalJson } from '@/app/workspace/[workspaceId]/lib/prefetch-internal-fetch' -import { KNOWLEDGE_BASE_LIST_STALE_TIME, knowledgeKeys } from '@/hooks/queries/kb/knowledge' +import { FOLDER_LIST_STALE_TIME, mapFolder } from '@/hooks/queries/folders' +import { KNOWLEDGE_BASE_LIST_STALE_TIME } from '@/hooks/queries/kb/knowledge' +import { folderKeys } from '@/hooks/queries/utils/folder-keys' +import { knowledgeKeys } from '@/hooks/queries/utils/knowledge-keys' /** - * Prefetches the workspace's knowledge-bases list under the same query key the - * client `useKnowledgeBasesQuery` hook uses (scope `active`), so the list paints - * populated on first render. + * Prefetches the workspace's knowledge-bases list AND its knowledge-base folder tree under + * the same query keys the client `useKnowledgeBasesQuery` / `useFolders` hooks use (scope + * `active`), so the list paints populated on first render. * - * The list carries `Date` fields, so it goes through the `/api/knowledge` route - * and caches the serialized wire shape — see {@link prefetchInternalJson}. + * Both are needed: a base row is only placed correctly relative to the folder rows it sits + * beside, so prefetching one without the other still flashes an ungrouped list — and a + * `?folderId=` deep link renders an empty breadcrumb until the folders arrive. + * + * The list carries `Date` fields, so it goes through the `/api/knowledge` route and caches the + * serialized wire shape — see {@link prefetchInternalJson}. Folders are mapped with the same + * `mapFolder` the hook applies, so the hydrated entry matches a client fetch exactly. */ export async function prefetchKnowledgeBases( queryClient: QueryClient, workspaceId: string ): Promise { - await queryClient.prefetchQuery({ - queryKey: knowledgeKeys.list(workspaceId, 'active'), - queryFn: async () => { - const result = await prefetchInternalJson<{ data: KnowledgeBaseData[] }>( - `/api/knowledge?workspaceId=${workspaceId}&scope=active` - ) - return result.data - }, - staleTime: KNOWLEDGE_BASE_LIST_STALE_TIME, - }) + await Promise.all([ + queryClient.prefetchQuery({ + queryKey: knowledgeKeys.list(workspaceId, 'active'), + queryFn: async () => { + const result = await prefetchInternalJson<{ data: KnowledgeBaseData[] }>( + `/api/knowledge?workspaceId=${workspaceId}&scope=active` + ) + return result.data + }, + staleTime: KNOWLEDGE_BASE_LIST_STALE_TIME, + }), + queryClient.prefetchQuery({ + queryKey: folderKeys.list(workspaceId, 'active', 'knowledge_base'), + queryFn: async () => { + const { folders } = await prefetchInternalJson<{ folders?: FolderApi[] }>( + `/api/folders?workspaceId=${workspaceId}&scope=active&resourceType=knowledge_base` + ) + return (folders ?? []).map(mapFolder) + }, + staleTime: FOLDER_LIST_STALE_TIME, + }), + ]) } diff --git a/apps/sim/app/workspace/[workspaceId]/lib/prefetch.test.ts b/apps/sim/app/workspace/[workspaceId]/lib/prefetch.test.ts index 7c5257c47a5..711754ff5e7 100644 --- a/apps/sim/app/workspace/[workspaceId]/lib/prefetch.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/lib/prefetch.test.ts @@ -20,8 +20,8 @@ import { prefetchFilesBrowser } from '@/app/workspace/[workspaceId]/files/prefet import { prefetchHomeLists } from '@/app/workspace/[workspaceId]/home/prefetch' import { prefetchKnowledgeBases } from '@/app/workspace/[workspaceId]/knowledge/prefetch' import { prefetchTables } from '@/app/workspace/[workspaceId]/tables/prefetch' -import { knowledgeKeys } from '@/hooks/queries/kb/knowledge' import { folderKeys } from '@/hooks/queries/utils/folder-keys' +import { knowledgeKeys } from '@/hooks/queries/utils/knowledge-keys' import { tableKeys } from '@/hooks/queries/utils/table-keys' import { workspaceFileFolderKeys } from '@/hooks/queries/workspace-file-folders' import { workspaceFilesKeys } from '@/hooks/queries/workspace-files' diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/knowledge-base-selector/knowledge-base-selector.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/knowledge-base-selector/knowledge-base-selector.tsx index 7500e739274..4643aec86c1 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/knowledge-base-selector/knowledge-base-selector.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/knowledge-base-selector/knowledge-base-selector.tsx @@ -13,7 +13,8 @@ import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/c import { useActiveSearchTarget } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/providers/active-search-target-provider' import type { SubBlockConfig } from '@/blocks/types' import { useKnowledgeBasesList } from '@/hooks/kb/use-knowledge' -import { fetchKnowledgeBase, knowledgeKeys } from '@/hooks/queries/kb/knowledge' +import { fetchKnowledgeBase } from '@/hooks/queries/kb/knowledge' +import { knowledgeKeys } from '@/hooks/queries/utils/knowledge-keys' interface KnowledgeBaseSelectorProps { blockId: string diff --git a/apps/sim/hooks/kb/use-knowledge-base-tag-definitions.ts b/apps/sim/hooks/kb/use-knowledge-base-tag-definitions.ts index 5ca10cbf7c5..84b62f55ad5 100644 --- a/apps/sim/hooks/kb/use-knowledge-base-tag-definitions.ts +++ b/apps/sim/hooks/kb/use-knowledge-base-tag-definitions.ts @@ -3,7 +3,8 @@ import { useCallback, useMemo } from 'react' import { useQueryClient } from '@tanstack/react-query' import type { AllTagSlot } from '@/lib/knowledge/constants' -import { knowledgeKeys, useTagDefinitionsQuery } from '@/hooks/queries/kb/knowledge' +import { useTagDefinitionsQuery } from '@/hooks/queries/kb/knowledge' +import { knowledgeKeys } from '@/hooks/queries/utils/knowledge-keys' export interface TagDefinition { id: string diff --git a/apps/sim/hooks/kb/use-knowledge.ts b/apps/sim/hooks/kb/use-knowledge.ts index dd7f45eff5b..5c1f18142c8 100644 --- a/apps/sim/hooks/kb/use-knowledge.ts +++ b/apps/sim/hooks/kb/use-knowledge.ts @@ -6,7 +6,6 @@ import { type DocumentTagFilter, type KnowledgeChunksResponse, type KnowledgeDocumentsResponse, - knowledgeKeys, serializeChunkParams, serializeDocumentParams, useDocumentQuery, @@ -15,6 +14,7 @@ import { useKnowledgeChunksQuery, useKnowledgeDocumentsQuery, } from '@/hooks/queries/kb/knowledge' +import { knowledgeKeys } from '@/hooks/queries/utils/knowledge-keys' const DEFAULT_PAGE_SIZE = 50 diff --git a/apps/sim/hooks/kb/use-tag-definitions.ts b/apps/sim/hooks/kb/use-tag-definitions.ts index 19e6efee8ac..01bb9c18f74 100644 --- a/apps/sim/hooks/kb/use-tag-definitions.ts +++ b/apps/sim/hooks/kb/use-tag-definitions.ts @@ -5,11 +5,11 @@ import { useQueryClient } from '@tanstack/react-query' import type { AllTagSlot } from '@/lib/knowledge/constants' import { type DocumentTagDefinitionInput, - knowledgeKeys, useDeleteDocumentTagDefinitions, useDocumentTagDefinitionsQuery, useSaveDocumentTagDefinitions, } from '@/hooks/queries/kb/knowledge' +import { knowledgeKeys } from '@/hooks/queries/utils/knowledge-keys' export interface TagDefinition { id: string diff --git a/apps/sim/hooks/queries/folders.ts b/apps/sim/hooks/queries/folders.ts index 6dec5c6a547..0f02b4c6c66 100644 --- a/apps/sim/hooks/queries/folders.ts +++ b/apps/sim/hooks/queries/folders.ts @@ -13,10 +13,10 @@ import { type ServedFolderResourceType, 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' +import { knowledgeKeys } from '@/hooks/queries/utils/knowledge-keys' import { createOptimisticMutationHandlers, generateTempId, @@ -152,9 +152,9 @@ function invalidateCascadedResourceLists( case 'knowledge_base': return queryClient.invalidateQueries({ queryKey: knowledgeKeys.lists() }) /** - * `file` has no case: the Files page reads and writes its folders through - * `/api/workspaces/[id]/files/folders/**`, which owns its own invalidation, so a - * cascade never reaches this hook for that tree. + * `file` has no case, and cannot reach here: `servedFolderResourceTypeSchema` does not + * serve it. Files reads and writes its folders through + * `/api/workspaces/[id]/files/folders/**`, which owns its own invalidation. */ default: return diff --git a/apps/sim/hooks/queries/kb/connectors.ts b/apps/sim/hooks/queries/kb/connectors.ts index a1291918bd0..fb31f7a90ef 100644 --- a/apps/sim/hooks/queries/kb/connectors.ts +++ b/apps/sim/hooks/queries/kb/connectors.ts @@ -15,7 +15,7 @@ import { triggerKnowledgeConnectorSyncContract, updateKnowledgeConnectorContract, } from '@/lib/api/contracts/knowledge' -import { knowledgeKeys } from '@/hooks/queries/kb/knowledge' +import { knowledgeKeys } from '@/hooks/queries/utils/knowledge-keys' const logger = createLogger('KnowledgeConnectorQueries') diff --git a/apps/sim/hooks/queries/kb/knowledge.ts b/apps/sim/hooks/queries/kb/knowledge.ts index 4fb7d4ce1aa..38055a942eb 100644 --- a/apps/sim/hooks/queries/kb/knowledge.ts +++ b/apps/sim/hooks/queries/kb/knowledge.ts @@ -27,7 +27,6 @@ import { type KnowledgeBaseData, type KnowledgeChunksResponse, type KnowledgeDocumentsResponse, - type KnowledgeScope, listDocumentTagDefinitionsContract, listKnowledgeBasesContract, listKnowledgeChunksContract, @@ -47,11 +46,10 @@ import { } from '@/lib/api/contracts/knowledge' import type { ChunkingStrategy, StrategyOptions } from '@/lib/chunkers/types' import type { DocumentSortField, SortOrder } from '@/lib/knowledge/documents/types' +import { type KnowledgeQueryScope, knowledgeKeys } from '@/hooks/queries/utils/knowledge-keys' const logger = createLogger('KnowledgeQueries') -type KnowledgeQueryScope = KnowledgeScope - export type { DocumentTagDefinitionData, DocumentTagFilter, @@ -71,30 +69,6 @@ export const KNOWLEDGE_TAG_DEFINITION_LIST_STALE_TIME = 60 * 1000 export const KNOWLEDGE_TAG_USAGE_STALE_TIME = 60 * 1000 export const KNOWLEDGE_DOCUMENT_TAG_DEFINITION_LIST_STALE_TIME = 60 * 1000 -export const knowledgeKeys = { - all: ['knowledge'] as const, - lists: () => [...knowledgeKeys.all, 'list'] as const, - list: (workspaceId?: string, scope: KnowledgeQueryScope = 'active') => - [...knowledgeKeys.lists(), workspaceId ?? 'all', scope] as const, - details: () => [...knowledgeKeys.all, 'detail'] as const, - detail: (knowledgeBaseId?: string) => - [...knowledgeKeys.details(), knowledgeBaseId ?? ''] as const, - tagDefinitions: (knowledgeBaseId: string) => - [...knowledgeKeys.detail(knowledgeBaseId), 'tagDefinitions'] as const, - tagUsage: (knowledgeBaseId: string) => - [...knowledgeKeys.detail(knowledgeBaseId), 'tagUsage'] as const, - documents: (knowledgeBaseId: string, paramsKey: string) => - [...knowledgeKeys.detail(knowledgeBaseId), 'documents', paramsKey] as const, - document: (knowledgeBaseId: string, documentId: string) => - [...knowledgeKeys.detail(knowledgeBaseId), 'document', documentId] as const, - documentTagDefinitions: (knowledgeBaseId: string, documentId: string) => - [...knowledgeKeys.document(knowledgeBaseId, documentId), 'tagDefinitions'] as const, - chunks: (knowledgeBaseId: string, documentId: string, paramsKey: string) => - [...knowledgeKeys.document(knowledgeBaseId, documentId), 'chunks', paramsKey] as const, - chunkSearch: (knowledgeBaseId: string, documentId: string, searchKey: string) => - [...knowledgeKeys.document(knowledgeBaseId, documentId), 'search', searchKey] as const, -} - export async function fetchKnowledgeBases( workspaceId?: string, scope: KnowledgeQueryScope = 'active', diff --git a/apps/sim/hooks/queries/utils/knowledge-keys.ts b/apps/sim/hooks/queries/utils/knowledge-keys.ts new file mode 100644 index 00000000000..6eb039e052d --- /dev/null +++ b/apps/sim/hooks/queries/utils/knowledge-keys.ts @@ -0,0 +1,37 @@ +import type { KnowledgeScope } from '@/lib/api/contracts/knowledge/base' + +/** + * React Query key factory for knowledge bases. + * + * Lives in this standalone (non-`'use client'`) module — like + * {@link file://./folder-keys.ts} and {@link file://./table-keys.ts} — so a server component + * or another query module can invalidate knowledge caches without importing + * `@/hooks/queries/kb/knowledge`, a ~1000-line hook module that pulls the `@sim/emcn` barrel + * in with it. That import edge is exactly the kind that lands a UI bundle in every workspace + * route's server prefetch. + */ +export type KnowledgeQueryScope = KnowledgeScope + +export const knowledgeKeys = { + all: ['knowledge'] as const, + lists: () => [...knowledgeKeys.all, 'list'] as const, + list: (workspaceId?: string, scope: KnowledgeQueryScope = 'active') => + [...knowledgeKeys.lists(), workspaceId ?? 'all', scope] as const, + details: () => [...knowledgeKeys.all, 'detail'] as const, + detail: (knowledgeBaseId?: string) => + [...knowledgeKeys.details(), knowledgeBaseId ?? ''] as const, + tagDefinitions: (knowledgeBaseId: string) => + [...knowledgeKeys.detail(knowledgeBaseId), 'tagDefinitions'] as const, + tagUsage: (knowledgeBaseId: string) => + [...knowledgeKeys.detail(knowledgeBaseId), 'tagUsage'] as const, + documents: (knowledgeBaseId: string, paramsKey: string) => + [...knowledgeKeys.detail(knowledgeBaseId), 'documents', paramsKey] as const, + document: (knowledgeBaseId: string, documentId: string) => + [...knowledgeKeys.detail(knowledgeBaseId), 'document', documentId] as const, + documentTagDefinitions: (knowledgeBaseId: string, documentId: string) => + [...knowledgeKeys.document(knowledgeBaseId, documentId), 'tagDefinitions'] as const, + chunks: (knowledgeBaseId: string, documentId: string, paramsKey: string) => + [...knowledgeKeys.document(knowledgeBaseId, documentId), 'chunks', paramsKey] as const, + chunkSearch: (knowledgeBaseId: string, documentId: string, searchKey: string) => + [...knowledgeKeys.document(knowledgeBaseId, documentId), 'search', searchKey] as const, +} diff --git a/apps/sim/lib/api/contracts/folders.test.ts b/apps/sim/lib/api/contracts/folders.test.ts index 05188549150..20c129c64bc 100644 --- a/apps/sim/lib/api/contracts/folders.test.ts +++ b/apps/sim/lib/api/contracts/folders.test.ts @@ -21,8 +21,15 @@ describe('servedFolderResourceTypeSchema', () => { }) it('accepts every tree the engine serves', () => { - for (const value of ['workflow', 'file', 'knowledge_base', 'table'] as const) { + for (const value of ['workflow', 'knowledge_base', 'table'] as const) { expect(servedFolderResourceTypeSchema.parse(value)).toBe(value) } }) + + it('does not serve file folders, which keep their own locked routes', () => { + // File folders live in `folder` after the cutover, but Files serializes every mutation + // behind an advisory lock and forbids `/` in names because a name is a path segment. + // Serving them here would be a second writer with neither property. + expect(servedFolderResourceTypeSchema.safeParse('file').success).toBe(false) + }) }) diff --git a/apps/sim/lib/api/contracts/folders.ts b/apps/sim/lib/api/contracts/folders.ts index 6a9c7ea4ac3..3dfdacee2d1 100644 --- a/apps/sim/lib/api/contracts/folders.ts +++ b/apps/sim/lib/api/contracts/folders.ts @@ -6,10 +6,16 @@ export const folderResourceTypeSchema = z.enum(['workflow', 'file', 'knowledge_b export type FolderResourceType = z.output /** - * The resource types the generic folder engine serves. Identical to - * {@link folderResourceTypeSchema} now that every type is backed by the `folder` table; - * kept as a separate schema so a future resource type can be added to the enum (and the - * DB) ahead of the routes that serve it. + * The resource types the generic folder engine SERVES over `/api/folders`. Deliberately a + * subset of {@link folderResourceTypeSchema}, which mirrors the DB enum. + * + * `file` is excluded on purpose even though file folders now live in the `folder` table. + * Files keeps its own routes (`/api/workspaces/[id]/files/folders/**`), and those serialize + * every mutation behind the `workspace_file_folders:${workspaceId}` advisory lock that makes + * their cycle and name checks atomic. Serving the same rows here would open a second writer + * that bypasses that lock — and would accept names containing `/`, `\`, `.` and `..`, which + * the file surface forbids because a file-folder name becomes a path segment. The storage + * cutover does not require a second API, so there isn't one. * * The `.default` applies ONLY to an omitted value — that is what keeps an old client, which * never sends the field, working against a new pod. A value that is present but not in the @@ -18,8 +24,8 @@ export type FolderResourceType = z.output * again. This is the deploy-ordering contract — see the note in the PR description. */ export const servedFolderResourceTypeSchema = z - .enum(['workflow', 'file', 'knowledge_base', 'table'], { - error: 'resourceType must be one of workflow, file, knowledge_base, table', + .enum(['workflow', 'knowledge_base', 'table'], { + error: 'resourceType must be one of workflow, knowledge_base, table', }) .default('workflow') diff --git a/apps/sim/lib/cleanup/batch-delete.ts b/apps/sim/lib/cleanup/batch-delete.ts index f7e6b8f3b09..10a04b3978a 100644 --- a/apps/sim/lib/cleanup/batch-delete.ts +++ b/apps/sim/lib/cleanup/batch-delete.ts @@ -82,11 +82,14 @@ export interface ChunkedBatchDeleteOptions { /** Runs between SELECT and DELETE; receives the just-selected rows. */ onBatch?: (rows: TRow[]) => Promise /** - * Re-asserted on the DELETE alongside the id list. Soft-delete sweeps MUST pass the same - * predicate they selected on: the DELETE runs after the SELECT and after `onBatch`, and a - * row restored in that window would otherwise be hard-deleted — taking the children a - * folder restore had just brought back with it. Rows that no longer match are simply not - * deleted and are counted as failed, so the next run re-evaluates them. + * Re-asserted on the DELETE alongside the id list. A soft-delete sweep whose `onBatch` does + * real work before the DELETE should pass the same predicate it selected on: a row restored + * in that window would otherwise be hard-deleted — taking the children a folder restore had + * just brought back with it. Rows that no longer match are simply not deleted and are + * counted as failed, so the next run re-evaluates them. + * + * Optional because a sweep with no `onBatch` closes a far smaller window; the hand-rolled + * targets in `cleanup-soft-deletes.ts` re-check inside their own DELETE instead. */ deleteFilter?: SQL batchSize?: number diff --git a/packages/db/migrations/0274_file_folder_cutover_catchup_backfill.sql b/packages/db/migrations/0274_file_folder_cutover_catchup_backfill.sql deleted file mode 100644 index 37a1d2adbc3..00000000000 --- a/packages/db/migrations/0274_file_folder_cutover_catchup_backfill.sql +++ /dev/null @@ -1,31 +0,0 @@ --- Catch-up backfill: file folders created AFTER migration 0272 ran. --- --- 0272 seeded `folder` with `resource_type = 'file'` from `workspace_file_folders`, but its --- backfill is guarded by `WHERE NOT EXISTS (SELECT 1 FROM folder WHERE resource_type = 'file')` --- so it fires exactly once. Every file folder created between that migration and the cutover --- in this PR was therefore written to `workspace_file_folders` only, and exists nowhere in --- `folder`. --- --- The cutover repoints all reads and writes at `folder`. Without this statement those folders --- disappear from the Files page on deploy, and the files inside them become unreachable — --- their `folder_id` would point at a row nothing reads any more. --- --- Safe to run repeatedly and safe to run while the old code is still serving: it only INSERTs --- rows that are missing, keyed by the source id (0272 preserved ids, so a folder is the same --- row in both tables). Parents and children go in one statement, so the self-referencing FK — --- checked as an AFTER-ROW trigger at end of statement — is satisfied regardless of row order. --- --- Name collisions cannot occur: every row comes from `workspace_file_folders`, whose own --- active-unique index enforces exactly the uniqueness `folder`'s partial index requires, and --- nothing other than these backfills ever writes `resource_type = 'file'`. The bare --- ON CONFLICT DO NOTHING is belt-and-braces for a concurrent re-run. -INSERT INTO "folder" (id, resource_type, name, user_id, workspace_id, parent_id, locked, sort_order, created_at, updated_at, deleted_at) -SELECT - f.id, 'file', f.name, - f.user_id, f.workspace_id, f.parent_id, false, f.sort_order, - f.created_at, f.updated_at, f.deleted_at -FROM "workspace_file_folders" f -WHERE NOT EXISTS ( - SELECT 1 FROM "folder" existing WHERE existing.id = f.id -) -ON CONFLICT DO NOTHING; diff --git a/packages/db/migrations/0274_file_folder_cutover_reconcile.sql b/packages/db/migrations/0274_file_folder_cutover_reconcile.sql new file mode 100644 index 00000000000..3518898a416 --- /dev/null +++ b/packages/db/migrations/0274_file_folder_cutover_reconcile.sql @@ -0,0 +1,63 @@ +-- Reconcile `folder` (resource_type = 'file') with `workspace_file_folders` before the cutover. +-- +-- 0272 seeded `folder` from `workspace_file_folders`, but its backfill is guarded by +-- `WHERE NOT EXISTS (SELECT 1 FROM folder WHERE resource_type = 'file')`, so it fires exactly +-- once — and nothing has written those rows since. The deployed manager reads and writes +-- `workspace_file_folders` EXCLUSIVELY. So `folder`'s file rows are frozen at the 0272 +-- snapshot while every rename, move, delete, and restore since then lives only in the legacy +-- table. +-- +-- The cutover in this PR repoints every read at `folder`. Without this migration: +-- * folders created after 0272 do not exist there at all — they vanish from the Files page +-- and their contents become unreachable; +-- * folders renamed or moved after 0272 revert to their old name and parent; +-- * folders deleted after 0272 reappear as active phantoms; +-- * a stale-active row can hold the name a newer folder legitimately took, so a plain +-- INSERT collides on `folder_workspace_resource_parent_name_active_unique`. +-- +-- `workspace_file_folders` is authoritative and ids were preserved, so this makes `folder` a +-- faithful mirror of it rather than a partial catch-up. +-- +-- IMPORTANT — run this AGAIN once the deploy has fully drained. Old pods keep writing the +-- legacy table until they are gone, so anything they write during the rolling window lands +-- after this runs. Both statements are idempotent and safe to repeat; a journaled migration +-- does not replay, so the post-drain pass is an operational step, not an automatic one. + +-- If pass 1 commits and pass 2 fails, the mirrored rows are left holding parked names. That is +-- invisible in production — the old code does not read `folder`'s file rows at all, and the new +-- code is not serving yet, because migrations run ahead of the deploy — and re-running this +-- migration repairs it. + +-- Pass 1: park every mirrored name out of the way. The partial unique index covers only +-- active rows, and `'__0274_tmp__' || id` is unique by construction, so after this no +-- pre-existing mirrored row can collide with the true state written below. Rows in `folder` +-- with no source row are left untouched by the join — at cutover time none can exist, since +-- nothing but 0272 has ever written `resource_type = 'file'`. +UPDATE "folder" f +SET "name" = '__0274_tmp__' || f."id" +FROM "workspace_file_folders" w +WHERE f."id" = w."id" + AND f."resource_type" = 'file' + AND f."deleted_at" IS NULL; +--> statement-breakpoint +-- Pass 2: insert what is missing and reconcile what diverged, in one statement so parents and +-- children land together — the self-referencing FK is checked as an AFTER-ROW trigger at end +-- of statement, and `folder_parent_resource_type_match` reads NULL for a parent inserted later +-- in the same statement and so does not fire. +-- +-- The conflict target is `(id)` DELIBERATELY, not the bare form: ids are the mirror key and +-- must reconcile, whereas a NAME collision here would mean `workspace_file_folders` itself +-- violated its own active-unique index. That must fail loudly rather than silently discard a +-- folder — the bare `ON CONFLICT DO NOTHING` would swallow it and strand every file inside. +INSERT INTO "folder" (id, resource_type, name, user_id, workspace_id, parent_id, locked, sort_order, created_at, updated_at, deleted_at) +SELECT + f.id, 'file', f.name, + f.user_id, f.workspace_id, f.parent_id, false, f.sort_order, + f.created_at, f.updated_at, f.deleted_at +FROM "workspace_file_folders" f +ON CONFLICT (id) DO UPDATE SET + "name" = EXCLUDED."name", + "parent_id" = EXCLUDED."parent_id", + "sort_order" = EXCLUDED."sort_order", + "updated_at" = EXCLUDED."updated_at", + "deleted_at" = EXCLUDED."deleted_at"; diff --git a/packages/db/migrations/meta/0274_snapshot.json b/packages/db/migrations/meta/0274_snapshot.json new file mode 100644 index 00000000000..d46de5e3bbc --- /dev/null +++ b/packages/db/migrations/meta/0274_snapshot.json @@ -0,0 +1,18236 @@ +{ + "id": "c39b8ee3-5b5f-4790-baf5-e224c95faba7", + "prevId": "8e11cbd9-0014-4685-b6eb-4aa80bc847d4", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.academy_certificate": { + "name": "academy_certificate", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "course_id": { + "name": "course_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "academy_cert_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "issued_at": { + "name": "issued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "certificate_number": { + "name": "certificate_number", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "academy_certificate_user_id_idx": { + "name": "academy_certificate_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_course_id_idx": { + "name": "academy_certificate_course_id_idx", + "columns": [ + { + "expression": "course_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_user_course_unique": { + "name": "academy_certificate_user_course_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "course_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_number_idx": { + "name": "academy_certificate_number_idx", + "columns": [ + { + "expression": "certificate_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_status_idx": { + "name": "academy_certificate_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "academy_certificate_user_id_user_id_fk": { + "name": "academy_certificate_user_id_user_id_fk", + "tableFrom": "academy_certificate", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "academy_certificate_certificate_number_unique": { + "name": "academy_certificate_certificate_number_unique", + "nullsNotDistinct": false, + "columns": ["certificate_number"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_user_id_idx": { + "name": "account_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_account_on_account_id_provider_id": { + "name": "idx_account_on_account_id_provider_id", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_key": { + "name": "api_key", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'personal'" + }, + "last_used": { + "name": "last_used", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "api_key_workspace_type_idx": { + "name": "api_key_workspace_type_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_key_user_type_idx": { + "name": "api_key_user_type_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_key_key_hash_idx": { + "name": "api_key_key_hash_idx", + "columns": [ + { + "expression": "key_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "api_key_user_id_user_id_fk": { + "name": "api_key_user_id_user_id_fk", + "tableFrom": "api_key", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "api_key_workspace_id_workspace_id_fk": { + "name": "api_key_workspace_id_workspace_id_fk", + "tableFrom": "api_key", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "api_key_created_by_user_id_fk": { + "name": "api_key_created_by_user_id_fk", + "tableFrom": "api_key", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "api_key_key_unique": { + "name": "api_key_key_unique", + "nullsNotDistinct": false, + "columns": ["key"] + } + }, + "policies": {}, + "checkConstraints": { + "workspace_type_check": { + "name": "workspace_type_check", + "value": "(type = 'workspace' AND workspace_id IS NOT NULL) OR (type = 'personal' AND workspace_id IS NULL)" + } + }, + "isRLSEnabled": false + }, + "public.async_jobs": { + "name": "async_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "run_at": { + "name": "run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "max_attempts": { + "name": "max_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 3 + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "output": { + "name": "output", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "async_jobs_status_started_at_idx": { + "name": "async_jobs_status_started_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_status_completed_at_idx": { + "name": "async_jobs_status_completed_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "completed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_schedule_pending_run_at_idx": { + "name": "async_jobs_schedule_pending_run_at_idx", + "columns": [ + { + "expression": "run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"async_jobs\".\"type\" = 'schedule-execution' AND \"async_jobs\".\"status\" = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_schedule_processing_started_at_idx": { + "name": "async_jobs_schedule_processing_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"async_jobs\".\"type\" = 'schedule-execution' AND \"async_jobs\".\"status\" = 'processing'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_log": { + "name": "audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_name": { + "name": "actor_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_email": { + "name": "actor_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resource_name": { + "name": "resource_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_log_workspace_created_idx": { + "name": "audit_log_workspace_created_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_workspace_created_at_id_idx": { + "name": "audit_log_workspace_created_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"created_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_actor_created_idx": { + "name": "audit_log_actor_created_idx", + "columns": [ + { + "expression": "actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_resource_idx": { + "name": "audit_log_resource_idx", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_action_idx": { + "name": "audit_log_action_idx", + "columns": [ + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_log_workspace_id_workspace_id_fk": { + "name": "audit_log_workspace_id_workspace_id_fk", + "tableFrom": "audit_log", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "audit_log_actor_id_user_id_fk": { + "name": "audit_log_actor_id_user_id_fk", + "tableFrom": "audit_log", + "tableTo": "user", + "columnsFrom": ["actor_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.background_work_status": { + "name": "background_work_status", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "background_work_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "background_work_status_value", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "background_work_status_workspace_status_idx": { + "name": "background_work_status_workspace_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "background_work_status_workflow_status_idx": { + "name": "background_work_status_workflow_status_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "background_work_status_meta_child_ws_idx": { + "name": "background_work_status_meta_child_ws_idx", + "columns": [ + { + "expression": "(\"metadata\" ->> 'childWorkspaceId')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "background_work_status_meta_other_ws_idx": { + "name": "background_work_status_meta_other_ws_idx", + "columns": [ + { + "expression": "(\"metadata\" ->> 'otherWorkspaceId')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "background_work_status_workspace_id_workspace_id_fk": { + "name": "background_work_status_workspace_id_workspace_id_fk", + "tableFrom": "background_work_status", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "background_work_status_workflow_id_workflow_id_fk": { + "name": "background_work_status_workflow_id_workflow_id_fk", + "tableFrom": "background_work_status", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat": { + "name": "chat", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "customizations": { + "name": "customizations", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowed_emails": { + "name": "allowed_emails", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "output_configs": { + "name": "output_configs", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "include_thinking": { + "name": "include_thinking", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "include_tool_calls": { + "name": "include_tool_calls", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "identifier_idx": { + "name": "identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"chat\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_archived_at_partial_idx": { + "name": "chat_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"chat\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_chat_on_workflow_id_archived_at": { + "name": "idx_chat_on_workflow_id_archived_at", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_workflow_id_workflow_id_fk": { + "name": "chat_workflow_id_workflow_id_fk", + "tableFrom": "chat", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chat_user_id_user_id_fk": { + "name": "chat_user_id_user_id_fk", + "tableFrom": "chat", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_async_tool_calls": { + "name": "copilot_async_tool_calls", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "checkpoint_id": { + "name": "checkpoint_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "tool_call_id": { + "name": "tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "args": { + "name": "args", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "status": { + "name": "status", + "type": "copilot_async_tool_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "permission_decision": { + "name": "permission_decision", + "type": "copilot_tool_permission_decision", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "permission_decided_at": { + "name": "permission_decided_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "claimed_by": { + "name": "claimed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_async_tool_calls_run_id_idx": { + "name": "copilot_async_tool_calls_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_checkpoint_id_idx": { + "name": "copilot_async_tool_calls_checkpoint_id_idx", + "columns": [ + { + "expression": "checkpoint_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_tool_call_id_idx": { + "name": "copilot_async_tool_calls_tool_call_id_idx", + "columns": [ + { + "expression": "tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_status_idx": { + "name": "copilot_async_tool_calls_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_run_status_idx": { + "name": "copilot_async_tool_calls_run_status_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_tool_call_id_unique": { + "name": "copilot_async_tool_calls_tool_call_id_unique", + "columns": [ + { + "expression": "tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_async_tool_calls_run_id_copilot_runs_id_fk": { + "name": "copilot_async_tool_calls_run_id_copilot_runs_id_fk", + "tableFrom": "copilot_async_tool_calls", + "tableTo": "copilot_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_async_tool_calls_checkpoint_id_copilot_run_checkpoints_id_fk": { + "name": "copilot_async_tool_calls_checkpoint_id_copilot_run_checkpoints_id_fk", + "tableFrom": "copilot_async_tool_calls", + "tableTo": "copilot_run_checkpoints", + "columnsFrom": ["checkpoint_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_chats": { + "name": "copilot_chats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "chat_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'copilot'" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'claude-3-7-sonnet-latest'" + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "preview_yaml": { + "name": "preview_yaml", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "plan_artifact": { + "name": "plan_artifact", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "resources": { + "name": "resources", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "auto_allowed_tools": { + "name": "auto_allowed_tools", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "pinned": { + "name": "pinned", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_chats_user_id_idx": { + "name": "copilot_chats_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_workflow_id_idx": { + "name": "copilot_chats_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_user_workflow_idx": { + "name": "copilot_chats_user_workflow_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_user_workspace_idx": { + "name": "copilot_chats_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_created_at_idx": { + "name": "copilot_chats_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_updated_at_idx": { + "name": "copilot_chats_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_workspace_created_at_id_idx": { + "name": "copilot_chats_workspace_created_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"created_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_user_workspace_deleted_partial_idx": { + "name": "copilot_chats_user_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_chats\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_chats_user_id_user_id_fk": { + "name": "copilot_chats_user_id_user_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_chats_workflow_id_workflow_id_fk": { + "name": "copilot_chats_workflow_id_workflow_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_chats_workspace_id_workspace_id_fk": { + "name": "copilot_chats_workspace_id_workspace_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_feedback": { + "name": "copilot_feedback", + "schema": "", + "columns": { + "feedback_id": { + "name": "feedback_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_query": { + "name": "user_query", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_response": { + "name": "agent_response", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_positive": { + "name": "is_positive", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "feedback": { + "name": "feedback", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_yaml": { + "name": "workflow_yaml", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_feedback_user_id_idx": { + "name": "copilot_feedback_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_chat_id_idx": { + "name": "copilot_feedback_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_user_chat_idx": { + "name": "copilot_feedback_user_chat_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_is_positive_idx": { + "name": "copilot_feedback_is_positive_idx", + "columns": [ + { + "expression": "is_positive", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_created_at_idx": { + "name": "copilot_feedback_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_feedback_user_id_user_id_fk": { + "name": "copilot_feedback_user_id_user_id_fk", + "tableFrom": "copilot_feedback", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_feedback_chat_id_copilot_chats_id_fk": { + "name": "copilot_feedback_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_feedback", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_messages": { + "name": "copilot_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "stream_id": { + "name": "stream_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parent_message_id": { + "name": "parent_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tokens_in": { + "name": "tokens_in", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "tokens_out": { + "name": "tokens_out", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_messages_chat_message_unique": { + "name": "copilot_messages_chat_message_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_chat_created_at_idx": { + "name": "copilot_messages_chat_created_at_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_chat_seq_idx": { + "name": "copilot_messages_chat_seq_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "seq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_chat_stream_idx": { + "name": "copilot_messages_chat_stream_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "stream_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"stream_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_user_created_at_idx": { + "name": "copilot_messages_user_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"role\" = 'user' AND \"copilot_messages\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_messages_chat_id_copilot_chats_id_fk": { + "name": "copilot_messages_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_messages", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_run_checkpoints": { + "name": "copilot_run_checkpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "pending_tool_call_id": { + "name": "pending_tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conversation_snapshot": { + "name": "conversation_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "agent_state": { + "name": "agent_state", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "provider_request": { + "name": "provider_request", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_run_checkpoints_run_id_idx": { + "name": "copilot_run_checkpoints_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_run_checkpoints_pending_tool_call_id_idx": { + "name": "copilot_run_checkpoints_pending_tool_call_id_idx", + "columns": [ + { + "expression": "pending_tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_run_checkpoints_run_pending_tool_unique": { + "name": "copilot_run_checkpoints_run_pending_tool_unique", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pending_tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_run_checkpoints_run_id_copilot_runs_id_fk": { + "name": "copilot_run_checkpoints_run_id_copilot_runs_id_fk", + "tableFrom": "copilot_run_checkpoints", + "tableTo": "copilot_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_runs": { + "name": "copilot_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_run_id": { + "name": "parent_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stream_id": { + "name": "stream_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent": { + "name": "agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "copilot_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "request_context": { + "name": "request_context", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "copilot_runs_execution_id_idx": { + "name": "copilot_runs_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_parent_run_id_idx": { + "name": "copilot_runs_parent_run_id_idx", + "columns": [ + { + "expression": "parent_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_chat_id_idx": { + "name": "copilot_runs_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_user_id_idx": { + "name": "copilot_runs_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_workflow_id_idx": { + "name": "copilot_runs_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_workspace_id_idx": { + "name": "copilot_runs_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_status_idx": { + "name": "copilot_runs_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_chat_execution_idx": { + "name": "copilot_runs_chat_execution_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_execution_started_at_idx": { + "name": "copilot_runs_execution_started_at_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_workspace_completed_at_id_idx": { + "name": "copilot_runs_workspace_completed_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"completed_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_stream_id_unique": { + "name": "copilot_runs_stream_id_unique", + "columns": [ + { + "expression": "stream_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_runs_chat_id_copilot_chats_id_fk": { + "name": "copilot_runs_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_user_id_user_id_fk": { + "name": "copilot_runs_user_id_user_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_workflow_id_workflow_id_fk": { + "name": "copilot_runs_workflow_id_workflow_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_workspace_id_workspace_id_fk": { + "name": "copilot_runs_workspace_id_workspace_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_workflow_read_hashes": { + "name": "copilot_workflow_read_hashes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_workflow_read_hashes_chat_id_idx": { + "name": "copilot_workflow_read_hashes_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_workflow_read_hashes_workflow_id_idx": { + "name": "copilot_workflow_read_hashes_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_workflow_read_hashes_chat_workflow_unique": { + "name": "copilot_workflow_read_hashes_chat_workflow_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_workflow_read_hashes_chat_id_copilot_chats_id_fk": { + "name": "copilot_workflow_read_hashes_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_workflow_read_hashes", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_workflow_read_hashes_workflow_id_workflow_id_fk": { + "name": "copilot_workflow_read_hashes_workflow_id_workflow_id_fk", + "tableFrom": "copilot_workflow_read_hashes", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credential": { + "name": "credential", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "credential_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env_key": { + "name": "env_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env_owner_user_id": { + "name": "env_owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_service_account_key": { + "name": "encrypted_service_account_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_workspace_id_idx": { + "name": "credential_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_type_idx": { + "name": "credential_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_provider_id_idx": { + "name": "credential_provider_id_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_account_id_idx": { + "name": "credential_account_id_idx", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_env_owner_user_id_idx": { + "name": "credential_env_owner_user_id_idx", + "columns": [ + { + "expression": "env_owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_account_unique": { + "name": "credential_workspace_account_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "account_id IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_env_unique": { + "name": "credential_workspace_env_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "type = 'env_workspace'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_personal_env_unique": { + "name": "credential_workspace_personal_env_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "type = 'env_personal'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_workspace_id_workspace_id_fk": { + "name": "credential_workspace_id_workspace_id_fk", + "tableFrom": "credential", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_account_id_account_id_fk": { + "name": "credential_account_id_account_id_fk", + "tableFrom": "credential", + "tableTo": "account", + "columnsFrom": ["account_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_env_owner_user_id_user_id_fk": { + "name": "credential_env_owner_user_id_user_id_fk", + "tableFrom": "credential", + "tableTo": "user", + "columnsFrom": ["env_owner_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_created_by_user_id_fk": { + "name": "credential_created_by_user_id_fk", + "tableFrom": "credential", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "credential_oauth_source_check": { + "name": "credential_oauth_source_check", + "value": "(type <> 'oauth') OR (account_id IS NOT NULL AND provider_id IS NOT NULL)" + }, + "credential_workspace_env_source_check": { + "name": "credential_workspace_env_source_check", + "value": "(type <> 'env_workspace') OR (env_key IS NOT NULL AND env_owner_user_id IS NULL)" + }, + "credential_personal_env_source_check": { + "name": "credential_personal_env_source_check", + "value": "(type <> 'env_personal') OR (env_key IS NOT NULL AND env_owner_user_id IS NOT NULL)" + } + }, + "isRLSEnabled": false + }, + "public.credential_member": { + "name": "credential_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "credential_member_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "status": { + "name": "status", + "type": "credential_member_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_member_user_id_idx": { + "name": "credential_member_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_role_idx": { + "name": "credential_member_role_idx", + "columns": [ + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_status_idx": { + "name": "credential_member_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_unique": { + "name": "credential_member_unique", + "columns": [ + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_member_credential_id_credential_id_fk": { + "name": "credential_member_credential_id_credential_id_fk", + "tableFrom": "credential_member", + "tableTo": "credential", + "columnsFrom": ["credential_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_member_user_id_user_id_fk": { + "name": "credential_member_user_id_user_id_fk", + "tableFrom": "credential_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_member_invited_by_user_id_fk": { + "name": "credential_member_invited_by_user_id_fk", + "tableFrom": "credential_member", + "tableTo": "user", + "columnsFrom": ["invited_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.custom_block": { + "name": "custom_block", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "icon_url": { + "name": "icon_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "inputs": { + "name": "inputs", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "outputs": { + "name": "outputs", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "custom_block_organization_id_idx": { + "name": "custom_block_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_block_workflow_id_idx": { + "name": "custom_block_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_block_organization_type_unique": { + "name": "custom_block_organization_type_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "custom_block_organization_id_organization_id_fk": { + "name": "custom_block_organization_id_organization_id_fk", + "tableFrom": "custom_block", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "custom_block_workflow_id_workflow_id_fk": { + "name": "custom_block_workflow_id_workflow_id_fk", + "tableFrom": "custom_block", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "custom_block_created_by_user_id_fk": { + "name": "custom_block_created_by_user_id_fk", + "tableFrom": "custom_block", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.custom_tools": { + "name": "custom_tools", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schema": { + "name": "schema", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "custom_tools_workspace_id_idx": { + "name": "custom_tools_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_tools_workspace_title_unique": { + "name": "custom_tools_workspace_title_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "title", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "custom_tools_workspace_id_workspace_id_fk": { + "name": "custom_tools_workspace_id_workspace_id_fk", + "tableFrom": "custom_tools", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "custom_tools_user_id_user_id_fk": { + "name": "custom_tools_user_id_user_id_fk", + "tableFrom": "custom_tools", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.data_drain_runs": { + "name": "data_drain_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "drain_id": { + "name": "drain_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "data_drain_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "trigger": { + "name": "trigger", + "type": "data_drain_run_trigger", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "rows_exported": { + "name": "rows_exported", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "bytes_written": { + "name": "bytes_written", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cursor_before": { + "name": "cursor_before", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cursor_after": { + "name": "cursor_after", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "locators": { + "name": "locators", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + } + }, + "indexes": { + "data_drain_runs_drain_started_idx": { + "name": "data_drain_runs_drain_started_idx", + "columns": [ + { + "expression": "drain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "data_drain_runs_drain_id_data_drains_id_fk": { + "name": "data_drain_runs_drain_id_data_drains_id_fk", + "tableFrom": "data_drain_runs", + "tableTo": "data_drains", + "columnsFrom": ["drain_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.data_drains": { + "name": "data_drains", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "data_drain_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "destination_type": { + "name": "destination_type", + "type": "data_drain_destination", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "destination_config": { + "name": "destination_config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "destination_credentials": { + "name": "destination_credentials", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schedule_cadence": { + "name": "schedule_cadence", + "type": "data_drain_cadence", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "cursor": { + "name": "cursor", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_success_at": { + "name": "last_success_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "data_drains_org_idx": { + "name": "data_drains_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "data_drains_due_idx": { + "name": "data_drains_due_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "data_drains_org_name_unique": { + "name": "data_drains_org_name_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "data_drains_organization_id_organization_id_fk": { + "name": "data_drains_organization_id_organization_id_fk", + "tableFrom": "data_drains", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "data_drains_created_by_user_id_fk": { + "name": "data_drains_created_by_user_id_fk", + "tableFrom": "data_drains", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.docs_embeddings": { + "name": "docs_embeddings", + "schema": "", + "columns": { + "chunk_id": { + "name": "chunk_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chunk_text": { + "name": "chunk_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_document": { + "name": "source_document", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_link": { + "name": "source_link", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "header_text": { + "name": "header_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "header_level": { + "name": "header_level", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": true + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "chunk_text_tsv": { + "name": "chunk_text_tsv", + "type": "tsvector", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "to_tsvector('english', \"docs_embeddings\".\"chunk_text\")", + "type": "stored" + } + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "docs_emb_source_document_idx": { + "name": "docs_emb_source_document_idx", + "columns": [ + { + "expression": "source_document", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_header_level_idx": { + "name": "docs_emb_header_level_idx", + "columns": [ + { + "expression": "header_level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_source_header_idx": { + "name": "docs_emb_source_header_idx", + "columns": [ + { + "expression": "source_document", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "header_level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_model_idx": { + "name": "docs_emb_model_idx", + "columns": [ + { + "expression": "embedding_model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_created_at_idx": { + "name": "docs_emb_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_embedding_vector_hnsw_idx": { + "name": "docs_embedding_vector_hnsw_idx", + "columns": [ + { + "expression": "embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "docs_emb_metadata_gin_idx": { + "name": "docs_emb_metadata_gin_idx", + "columns": [ + { + "expression": "metadata", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "docs_emb_chunk_text_fts_idx": { + "name": "docs_emb_chunk_text_fts_idx", + "columns": [ + { + "expression": "chunk_text_tsv", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "docs_embedding_not_null_check": { + "name": "docs_embedding_not_null_check", + "value": "\"embedding\" IS NOT NULL" + }, + "docs_header_level_check": { + "name": "docs_header_level_check", + "value": "\"header_level\" >= 1 AND \"header_level\" <= 6" + } + }, + "isRLSEnabled": false + }, + "public.document": { + "name": "document", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_url": { + "name": "file_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "file_size": { + "name": "file_size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chunk_count": { + "name": "chunk_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "character_count": { + "name": "character_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "processing_status": { + "name": "processing_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "processing_started_at": { + "name": "processing_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "processing_completed_at": { + "name": "processing_completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "processing_error": { + "name": "processing_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "user_excluded": { + "name": "user_excluded", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "tag1": { + "name": "tag1", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag2": { + "name": "tag2", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag3": { + "name": "tag3", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag4": { + "name": "tag4", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag5": { + "name": "tag5", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag6": { + "name": "tag6", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag7": { + "name": "tag7", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "number1": { + "name": "number1", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number2": { + "name": "number2", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number3": { + "name": "number3", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number4": { + "name": "number4", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number5": { + "name": "number5", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "date1": { + "name": "date1", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "date2": { + "name": "date2", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "boolean1": { + "name": "boolean1", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean2": { + "name": "boolean2", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean3": { + "name": "boolean3", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "connector_id": { + "name": "connector_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_url": { + "name": "source_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "doc_kb_id_idx": { + "name": "doc_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_filename_idx": { + "name": "doc_filename_idx", + "columns": [ + { + "expression": "filename", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_processing_status_idx": { + "name": "doc_processing_status_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "processing_status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_connector_external_id_idx": { + "name": "doc_connector_external_id_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"document\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_connector_id_idx": { + "name": "doc_connector_id_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_storage_key_idx": { + "name": "doc_storage_key_idx", + "columns": [ + { + "expression": "storage_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"storage_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_archived_at_partial_idx": { + "name": "doc_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_deleted_at_partial_idx": { + "name": "doc_deleted_at_partial_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag1_idx": { + "name": "doc_tag1_idx", + "columns": [ + { + "expression": "tag1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag2_idx": { + "name": "doc_tag2_idx", + "columns": [ + { + "expression": "tag2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag3_idx": { + "name": "doc_tag3_idx", + "columns": [ + { + "expression": "tag3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag4_idx": { + "name": "doc_tag4_idx", + "columns": [ + { + "expression": "tag4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag5_idx": { + "name": "doc_tag5_idx", + "columns": [ + { + "expression": "tag5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag6_idx": { + "name": "doc_tag6_idx", + "columns": [ + { + "expression": "tag6", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag7_idx": { + "name": "doc_tag7_idx", + "columns": [ + { + "expression": "tag7", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number1_idx": { + "name": "doc_number1_idx", + "columns": [ + { + "expression": "number1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number2_idx": { + "name": "doc_number2_idx", + "columns": [ + { + "expression": "number2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number3_idx": { + "name": "doc_number3_idx", + "columns": [ + { + "expression": "number3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number4_idx": { + "name": "doc_number4_idx", + "columns": [ + { + "expression": "number4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number5_idx": { + "name": "doc_number5_idx", + "columns": [ + { + "expression": "number5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_date1_idx": { + "name": "doc_date1_idx", + "columns": [ + { + "expression": "date1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_date2_idx": { + "name": "doc_date2_idx", + "columns": [ + { + "expression": "date2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean1_idx": { + "name": "doc_boolean1_idx", + "columns": [ + { + "expression": "boolean1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean2_idx": { + "name": "doc_boolean2_idx", + "columns": [ + { + "expression": "boolean2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean3_idx": { + "name": "doc_boolean3_idx", + "columns": [ + { + "expression": "boolean3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "document_knowledge_base_id_knowledge_base_id_fk": { + "name": "document_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "document", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "document_connector_id_knowledge_connector_id_fk": { + "name": "document_connector_id_knowledge_connector_id_fk", + "tableFrom": "document", + "tableTo": "knowledge_connector", + "columnsFrom": ["connector_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "document_uploaded_by_user_id_fk": { + "name": "document_uploaded_by_user_id_fk", + "tableFrom": "document", + "tableTo": "user", + "columnsFrom": ["uploaded_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.embedding": { + "name": "embedding", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chunk_index": { + "name": "chunk_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "chunk_hash": { + "name": "chunk_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_length": { + "name": "content_length", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": false + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "start_offset": { + "name": "start_offset", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "end_offset": { + "name": "end_offset", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "tag1": { + "name": "tag1", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag2": { + "name": "tag2", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag3": { + "name": "tag3", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag4": { + "name": "tag4", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag5": { + "name": "tag5", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag6": { + "name": "tag6", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag7": { + "name": "tag7", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "number1": { + "name": "number1", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number2": { + "name": "number2", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number3": { + "name": "number3", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number4": { + "name": "number4", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number5": { + "name": "number5", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "date1": { + "name": "date1", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "date2": { + "name": "date2", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "boolean1": { + "name": "boolean1", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean2": { + "name": "boolean2", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean3": { + "name": "boolean3", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "content_tsv": { + "name": "content_tsv", + "type": "tsvector", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "to_tsvector('english', \"embedding\".\"content\")", + "type": "stored" + } + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "emb_kb_id_idx": { + "name": "emb_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_id_idx": { + "name": "emb_doc_id_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_chunk_idx": { + "name": "emb_doc_chunk_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chunk_index", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_model_idx": { + "name": "emb_kb_model_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "embedding_model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_enabled_idx": { + "name": "emb_kb_enabled_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_enabled_idx": { + "name": "emb_doc_enabled_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "embedding_vector_hnsw_idx": { + "name": "embedding_vector_hnsw_idx", + "columns": [ + { + "expression": "embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "emb_tag1_idx": { + "name": "emb_tag1_idx", + "columns": [ + { + "expression": "tag1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag2_idx": { + "name": "emb_tag2_idx", + "columns": [ + { + "expression": "tag2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag3_idx": { + "name": "emb_tag3_idx", + "columns": [ + { + "expression": "tag3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag4_idx": { + "name": "emb_tag4_idx", + "columns": [ + { + "expression": "tag4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag5_idx": { + "name": "emb_tag5_idx", + "columns": [ + { + "expression": "tag5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag6_idx": { + "name": "emb_tag6_idx", + "columns": [ + { + "expression": "tag6", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag7_idx": { + "name": "emb_tag7_idx", + "columns": [ + { + "expression": "tag7", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number1_idx": { + "name": "emb_number1_idx", + "columns": [ + { + "expression": "number1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number2_idx": { + "name": "emb_number2_idx", + "columns": [ + { + "expression": "number2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number3_idx": { + "name": "emb_number3_idx", + "columns": [ + { + "expression": "number3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number4_idx": { + "name": "emb_number4_idx", + "columns": [ + { + "expression": "number4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number5_idx": { + "name": "emb_number5_idx", + "columns": [ + { + "expression": "number5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_date1_idx": { + "name": "emb_date1_idx", + "columns": [ + { + "expression": "date1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_date2_idx": { + "name": "emb_date2_idx", + "columns": [ + { + "expression": "date2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_boolean1_idx": { + "name": "emb_boolean1_idx", + "columns": [ + { + "expression": "boolean1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_boolean2_idx": { + "name": "emb_boolean2_idx", + "columns": [ + { + "expression": "boolean2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_boolean3_idx": { + "name": "emb_boolean3_idx", + "columns": [ + { + "expression": "boolean3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_content_fts_idx": { + "name": "emb_content_fts_idx", + "columns": [ + { + "expression": "content_tsv", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "embedding_knowledge_base_id_knowledge_base_id_fk": { + "name": "embedding_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "embedding", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "embedding_document_id_document_id_fk": { + "name": "embedding_document_id_document_id_fk", + "tableFrom": "embedding", + "tableTo": "document", + "columnsFrom": ["document_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "embedding_not_null_check": { + "name": "embedding_not_null_check", + "value": "\"embedding\" IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.environment": { + "name": "environment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "environment_user_id_user_id_fk": { + "name": "environment_user_id_user_id_fk", + "tableFrom": "environment", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "environment_user_id_unique": { + "name": "environment_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.execution_large_value_dependencies": { + "name": "execution_large_value_dependencies", + "schema": "", + "columns": { + "parent_key": { + "name": "parent_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_key": { + "name": "child_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "execution_large_value_dependencies_workspace_parent_key_idx": { + "name": "execution_large_value_dependencies_workspace_parent_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_value_dependencies_workspace_child_key_idx": { + "name": "execution_large_value_dependencies_workspace_child_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "child_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "execution_large_value_dependencies_workspace_id_workspace_id_fk": { + "name": "execution_large_value_dependencies_workspace_id_workspace_id_fk", + "tableFrom": "execution_large_value_dependencies", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "execution_large_value_dependencies_parent_key_child_key_pk": { + "name": "execution_large_value_dependencies_parent_key_child_key_pk", + "columns": ["parent_key", "child_key"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.execution_large_value_references": { + "name": "execution_large_value_references", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "execution_large_value_reference_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "execution_large_value_references_workspace_execution_source_idx": { + "name": "execution_large_value_references_workspace_execution_source_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "execution_large_value_references_workspace_id_workspace_id_fk": { + "name": "execution_large_value_references_workspace_id_workspace_id_fk", + "tableFrom": "execution_large_value_references", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "execution_large_value_references_workflow_id_workflow_id_fk": { + "name": "execution_large_value_references_workflow_id_workflow_id_fk", + "tableFrom": "execution_large_value_references", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "execution_large_value_references_key_execution_id_source_pk": { + "name": "execution_large_value_references_key_execution_id_source_pk", + "columns": ["key", "execution_id", "source"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.execution_large_values": { + "name": "execution_large_values", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_execution_id": { + "name": "owner_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "execution_large_values_owner_execution_id_idx": { + "name": "execution_large_values_owner_execution_id_idx", + "columns": [ + { + "expression": "owner_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_values_cleanup_idx": { + "name": "execution_large_values_cleanup_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"execution_large_values\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_values_tombstone_cleanup_idx": { + "name": "execution_large_values_tombstone_cleanup_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"execution_large_values\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "execution_large_values_workspace_id_workspace_id_fk": { + "name": "execution_large_values_workspace_id_workspace_id_fk", + "tableFrom": "execution_large_values", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "execution_large_values_workflow_id_workflow_id_fk": { + "name": "execution_large_values_workflow_id_workflow_id_fk", + "tableFrom": "execution_large_values", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.folder": { + "name": "folder", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "folder_resource_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "folder_user_idx": { + "name": "folder_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "folder_workspace_resource_parent_idx": { + "name": "folder_workspace_resource_parent_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "folder_parent_sort_idx": { + "name": "folder_parent_sort_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "folder_deleted_at_idx": { + "name": "folder_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "folder_workspace_deleted_partial_idx": { + "name": "folder_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"folder\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "folder_workspace_resource_parent_name_active_unique": { + "name": "folder_workspace_resource_parent_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"parent_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"folder\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "folder_user_id_user_id_fk": { + "name": "folder_user_id_user_id_fk", + "tableFrom": "folder", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "folder_workspace_id_workspace_id_fk": { + "name": "folder_workspace_id_workspace_id_fk", + "tableFrom": "folder", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "folder_parent_id_folder_id_fk": { + "name": "folder_parent_id_folder_id_fk", + "tableFrom": "folder", + "tableTo": "folder", + "columnsFrom": ["parent_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.idempotency_key": { + "name": "idempotency_key", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "result": { + "name": "result", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idempotency_key_created_at_idx": { + "name": "idempotency_key_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation": { + "name": "invitation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "invitation_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'organization'" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "membership_intent": { + "name": "membership_intent", + "type": "invitation_membership_intent", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'internal'" + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "invitation_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invitation_email_idx": { + "name": "invitation_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_organization_id_idx": { + "name": "invitation_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_status_idx": { + "name": "invitation_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_pending_email_org_unique": { + "name": "invitation_pending_email_org_unique", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"invitation\".\"status\" = 'pending' AND \"invitation\".\"organization_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitation_inviter_id_user_id_fk": { + "name": "invitation_inviter_id_user_id_fk", + "tableFrom": "invitation", + "tableTo": "user", + "columnsFrom": ["inviter_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_organization_id_organization_id_fk": { + "name": "invitation_organization_id_organization_id_fk", + "tableFrom": "invitation", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "invitation_token_unique": { + "name": "invitation_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation_workspace_grant": { + "name": "invitation_workspace_grant", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "invitation_id": { + "name": "invitation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission": { + "name": "permission", + "type": "permission_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invitation_workspace_grant_unique": { + "name": "invitation_workspace_grant_unique", + "columns": [ + { + "expression": "invitation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_workspace_grant_workspace_id_idx": { + "name": "invitation_workspace_grant_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitation_workspace_grant_invitation_id_invitation_id_fk": { + "name": "invitation_workspace_grant_invitation_id_invitation_id_fk", + "tableFrom": "invitation_workspace_grant", + "tableTo": "invitation", + "columnsFrom": ["invitation_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_workspace_grant_workspace_id_workspace_id_fk": { + "name": "invitation_workspace_grant_workspace_id_workspace_id_fk", + "tableFrom": "invitation_workspace_grant", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.job_execution_logs": { + "name": "job_execution_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "schedule_id": { + "name": "schedule_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_duration_ms": { + "name": "total_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "execution_data": { + "name": "execution_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "cost": { + "name": "cost", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "job_execution_logs_schedule_id_idx": { + "name": "job_execution_logs_schedule_id_idx", + "columns": [ + { + "expression": "schedule_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_workspace_started_at_idx": { + "name": "job_execution_logs_workspace_started_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_workspace_ended_at_id_idx": { + "name": "job_execution_logs_workspace_ended_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"ended_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_execution_id_unique": { + "name": "job_execution_logs_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_trigger_idx": { + "name": "job_execution_logs_trigger_idx", + "columns": [ + { + "expression": "trigger", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "job_execution_logs_schedule_id_workflow_schedule_id_fk": { + "name": "job_execution_logs_schedule_id_workflow_schedule_id_fk", + "tableFrom": "job_execution_logs", + "tableTo": "workflow_schedule", + "columnsFrom": ["schedule_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "job_execution_logs_workspace_id_workspace_id_fk": { + "name": "job_execution_logs_workspace_id_workspace_id_fk", + "tableFrom": "job_execution_logs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_base": { + "name": "knowledge_base", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "embedding_dimension": { + "name": "embedding_dimension", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1536 + }, + "chunking_config": { + "name": "chunking_config", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{\"maxSize\": 1024, \"minSize\": 1, \"overlap\": 200}'" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "kb_user_id_idx": { + "name": "kb_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_workspace_id_idx": { + "name": "kb_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_user_workspace_idx": { + "name": "kb_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_folder_id_idx": { + "name": "kb_folder_id_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_deleted_at_idx": { + "name": "kb_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_workspace_deleted_partial_idx": { + "name": "kb_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_base\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_workspace_name_active_unique": { + "name": "kb_workspace_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"knowledge_base\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_base_user_id_user_id_fk": { + "name": "knowledge_base_user_id_user_id_fk", + "tableFrom": "knowledge_base", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "knowledge_base_workspace_id_workspace_id_fk": { + "name": "knowledge_base_workspace_id_workspace_id_fk", + "tableFrom": "knowledge_base", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "knowledge_base_folder_id_folder_id_fk": { + "name": "knowledge_base_folder_id_folder_id_fk", + "tableFrom": "knowledge_base", + "tableTo": "folder", + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_base_tag_definitions": { + "name": "knowledge_base_tag_definitions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tag_slot": { + "name": "tag_slot", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "field_type": { + "name": "field_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "kb_tag_definitions_kb_slot_idx": { + "name": "kb_tag_definitions_kb_slot_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tag_slot", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_tag_definitions_kb_display_name_idx": { + "name": "kb_tag_definitions_kb_display_name_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "display_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_tag_definitions_kb_id_idx": { + "name": "kb_tag_definitions_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_base_tag_definitions_knowledge_base_id_knowledge_base_id_fk": { + "name": "knowledge_base_tag_definitions_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "knowledge_base_tag_definitions", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_connector": { + "name": "knowledge_connector", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connector_type": { + "name": "connector_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_api_key": { + "name": "encrypted_api_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_config": { + "name": "source_config", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "sync_mode": { + "name": "sync_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'full'" + }, + "sync_interval_minutes": { + "name": "sync_interval_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1440 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "last_sync_at": { + "name": "last_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_sync_error": { + "name": "last_sync_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_sync_doc_count": { + "name": "last_sync_doc_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "next_sync_at": { + "name": "next_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "consecutive_failures": { + "name": "consecutive_failures", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "kc_knowledge_base_id_idx": { + "name": "kc_knowledge_base_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_status_next_sync_idx": { + "name": "kc_status_next_sync_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_sync_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_archived_at_partial_idx": { + "name": "kc_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_connector\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_deleted_at_partial_idx": { + "name": "kc_deleted_at_partial_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_connector\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_connector_knowledge_base_id_knowledge_base_id_fk": { + "name": "knowledge_connector_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "knowledge_connector", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_connector_sync_log": { + "name": "knowledge_connector_sync_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connector_id": { + "name": "connector_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "docs_added": { + "name": "docs_added", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_updated": { + "name": "docs_updated", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_deleted": { + "name": "docs_deleted", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_unchanged": { + "name": "docs_unchanged", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_failed": { + "name": "docs_failed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "kcsl_connector_id_idx": { + "name": "kcsl_connector_id_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_connector_sync_log_connector_id_knowledge_connector_id_fk": { + "name": "knowledge_connector_sync_log_connector_id_knowledge_connector_id_fk", + "tableFrom": "knowledge_connector_sync_log", + "tableTo": "knowledge_connector", + "columnsFrom": ["connector_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_server_oauth": { + "name": "mcp_server_oauth", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "mcp_server_id": { + "name": "mcp_server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_information": { + "name": "client_information", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tokens": { + "name": "tokens", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "code_verifier": { + "name": "code_verifier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state_created_at": { + "name": "state_created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_refreshed_at": { + "name": "last_refreshed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_server_oauth_server_unique": { + "name": "mcp_server_oauth_server_unique", + "columns": [ + { + "expression": "mcp_server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_server_oauth_state_idx": { + "name": "mcp_server_oauth_state_idx", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_server_oauth_mcp_server_id_mcp_servers_id_fk": { + "name": "mcp_server_oauth_mcp_server_id_mcp_servers_id_fk", + "tableFrom": "mcp_server_oauth", + "tableTo": "mcp_servers", + "columnsFrom": ["mcp_server_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_server_oauth_user_id_user_id_fk": { + "name": "mcp_server_oauth_user_id_user_id_fk", + "tableFrom": "mcp_server_oauth", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "mcp_server_oauth_workspace_id_workspace_id_fk": { + "name": "mcp_server_oauth_workspace_id_workspace_id_fk", + "tableFrom": "mcp_server_oauth", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_servers": { + "name": "mcp_servers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "transport": { + "name": "transport", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'headers'" + }, + "oauth_client_id": { + "name": "oauth_client_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oauth_client_secret": { + "name": "oauth_client_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "headers": { + "name": "headers", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "timeout": { + "name": "timeout", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 30000 + }, + "retries": { + "name": "retries", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 3 + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_connected": { + "name": "last_connected", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "connection_status": { + "name": "connection_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'disconnected'" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status_config": { + "name": "status_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "tool_count": { + "name": "tool_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_tools_refresh": { + "name": "last_tools_refresh", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_requests": { + "name": "total_requests", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_used": { + "name": "last_used", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_servers_workspace_enabled_idx": { + "name": "mcp_servers_workspace_enabled_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_servers_workspace_deleted_partial_idx": { + "name": "mcp_servers_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"mcp_servers\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_servers_workspace_id_workspace_id_fk": { + "name": "mcp_servers_workspace_id_workspace_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_servers_created_by_user_id_fk": { + "name": "mcp_servers_created_by_user_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.member": { + "name": "member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "member_user_id_unique": { + "name": "member_user_id_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "member_organization_id_idx": { + "name": "member_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "member_user_id_user_id_fk": { + "name": "member_user_id_user_id_fk", + "tableFrom": "member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "member_organization_id_organization_id_fk": { + "name": "member_organization_id_organization_id_fk", + "tableFrom": "member", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memory": { + "name": "memory", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "memory_key_idx": { + "name": "memory_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workspace_idx": { + "name": "memory_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workspace_key_idx": { + "name": "memory_workspace_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workspace_deleted_partial_idx": { + "name": "memory_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"memory\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "memory_workspace_id_workspace_id_fk": { + "name": "memory_workspace_id_workspace_id_fk", + "tableFrom": "memory", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_inbox_allowed_sender": { + "name": "mothership_inbox_allowed_sender", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "added_by": { + "name": "added_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "inbox_sender_ws_email_idx": { + "name": "inbox_sender_ws_email_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mothership_inbox_allowed_sender_workspace_id_workspace_id_fk": { + "name": "mothership_inbox_allowed_sender_workspace_id_workspace_id_fk", + "tableFrom": "mothership_inbox_allowed_sender", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mothership_inbox_allowed_sender_added_by_user_id_fk": { + "name": "mothership_inbox_allowed_sender_added_by_user_id_fk", + "tableFrom": "mothership_inbox_allowed_sender", + "tableTo": "user", + "columnsFrom": ["added_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_inbox_task": { + "name": "mothership_inbox_task", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "from_email": { + "name": "from_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "from_name": { + "name": "from_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body_preview": { + "name": "body_preview", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "body_text": { + "name": "body_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "body_html": { + "name": "body_html", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_message_id": { + "name": "email_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "in_reply_to": { + "name": "in_reply_to", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "response_message_id": { + "name": "response_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agentmail_message_id": { + "name": "agentmail_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'received'" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "trigger_job_id": { + "name": "trigger_job_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "result_summary": { + "name": "result_summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rejection_reason": { + "name": "rejection_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "has_attachments": { + "name": "has_attachments", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "cc_recipients": { + "name": "cc_recipients", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "processing_started_at": { + "name": "processing_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "inbox_task_ws_created_at_idx": { + "name": "inbox_task_ws_created_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inbox_task_ws_status_idx": { + "name": "inbox_task_ws_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inbox_task_response_msg_id_idx": { + "name": "inbox_task_response_msg_id_idx", + "columns": [ + { + "expression": "response_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inbox_task_email_msg_id_idx": { + "name": "inbox_task_email_msg_id_idx", + "columns": [ + { + "expression": "email_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mothership_inbox_task_workspace_id_workspace_id_fk": { + "name": "mothership_inbox_task_workspace_id_workspace_id_fk", + "tableFrom": "mothership_inbox_task", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mothership_inbox_task_chat_id_copilot_chats_id_fk": { + "name": "mothership_inbox_task_chat_id_copilot_chats_id_fk", + "tableFrom": "mothership_inbox_task", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_inbox_webhook": { + "name": "mothership_inbox_webhook", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "webhook_id": { + "name": "webhook_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "mothership_inbox_webhook_workspace_id_workspace_id_fk": { + "name": "mothership_inbox_webhook_workspace_id_workspace_id_fk", + "tableFrom": "mothership_inbox_webhook", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "mothership_inbox_webhook_workspace_id_unique": { + "name": "mothership_inbox_webhook_workspace_id_unique", + "nullsNotDistinct": false, + "columns": ["workspace_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_settings": { + "name": "mothership_settings", + "schema": "", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "mcp_tool_refs": { + "name": "mcp_tool_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "custom_tool_refs": { + "name": "custom_tool_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "skill_refs": { + "name": "skill_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mothership_settings_workspace_id_idx": { + "name": "mothership_settings_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mothership_settings_workspace_id_workspace_id_fk": { + "name": "mothership_settings_workspace_id_workspace_id_fk", + "tableFrom": "mothership_settings", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization": { + "name": "organization", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "session_policy_settings": { + "name": "session_policy_settings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "security_policy_version": { + "name": "security_policy_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "whitelabel_settings": { + "name": "whitelabel_settings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "data_retention_settings": { + "name": "data_retention_settings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "org_usage_limit": { + "name": "org_usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "limit_notifications": { + "name": "limit_notifications", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "departed_member_usage": { + "name": "departed_member_usage", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "credit_balance": { + "name": "credit_balance", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_member_usage_limit": { + "name": "organization_member_usage_limit", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "usage_limit": { + "name": "usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "set_by": { + "name": "set_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "org_member_usage_limit_org_user_unique": { + "name": "org_member_usage_limit_org_user_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "org_member_usage_limit_organization_id_idx": { + "name": "org_member_usage_limit_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "organization_member_usage_limit_organization_id_organization_id_fk": { + "name": "organization_member_usage_limit_organization_id_organization_id_fk", + "tableFrom": "organization_member_usage_limit", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_member_usage_limit_user_id_user_id_fk": { + "name": "organization_member_usage_limit_user_id_user_id_fk", + "tableFrom": "organization_member_usage_limit", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_member_usage_limit_set_by_user_id_fk": { + "name": "organization_member_usage_limit_set_by_user_id_fk", + "tableFrom": "organization_member_usage_limit", + "tableTo": "user", + "columnsFrom": ["set_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.outbox_event": { + "name": "outbox_event", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "max_attempts": { + "name": "max_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10 + }, + "available_at": { + "name": "available_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "locked_at": { + "name": "locked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "outbox_event_status_available_idx": { + "name": "outbox_event_status_available_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "available_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "outbox_event_locked_at_idx": { + "name": "outbox_event_locked_at_idx", + "columns": [ + { + "expression": "locked_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "outbox_event_type_created_idx": { + "name": "outbox_event_type_created_idx", + "columns": [ + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.paused_executions": { + "name": "paused_executions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_snapshot": { + "name": "execution_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "pause_points": { + "name": "pause_points", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "total_pause_count": { + "name": "total_pause_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "resumed_count": { + "name": "resumed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "automatic_resume_retry_count": { + "name": "automatic_resume_retry_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'paused'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "paused_at": { + "name": "paused_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "next_resume_at": { + "name": "next_resume_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "paused_executions_workflow_id_idx": { + "name": "paused_executions_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paused_executions_status_idx": { + "name": "paused_executions_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paused_executions_execution_id_unique": { + "name": "paused_executions_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paused_executions_next_resume_at_idx": { + "name": "paused_executions_next_resume_at_idx", + "columns": [ + { + "expression": "next_resume_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "status = 'paused' AND next_resume_at IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "paused_executions_workflow_id_workflow_id_fk": { + "name": "paused_executions_workflow_id_workflow_id_fk", + "tableFrom": "paused_executions", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pending_credential_draft": { + "name": "pending_credential_draft", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pending_draft_user_provider_ws": { + "name": "pending_draft_user_provider_ws", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pending_credential_draft_user_id_user_id_fk": { + "name": "pending_credential_draft_user_id_user_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pending_credential_draft_workspace_id_workspace_id_fk": { + "name": "pending_credential_draft_workspace_id_workspace_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pending_credential_draft_credential_id_credential_id_fk": { + "name": "pending_credential_draft_credential_id_credential_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "credential", + "columnsFrom": ["credential_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission_group": { + "name": "permission_group", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "permission_group_created_by_idx": { + "name": "permission_group_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_organization_name_unique": { + "name": "permission_group_organization_name_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_organization_default_unique": { + "name": "permission_group_organization_default_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "is_default = true", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_group_organization_id_organization_id_fk": { + "name": "permission_group_organization_id_organization_id_fk", + "tableFrom": "permission_group", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_created_by_user_id_fk": { + "name": "permission_group_created_by_user_id_fk", + "tableFrom": "permission_group", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission_group_member": { + "name": "permission_group_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "permission_group_id": { + "name": "permission_group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "assigned_by": { + "name": "assigned_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assigned_at": { + "name": "assigned_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permission_group_member_group_id_idx": { + "name": "permission_group_member_group_id_idx", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_member_group_user_unique": { + "name": "permission_group_member_group_user_unique", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_member_organization_user_idx": { + "name": "permission_group_member_organization_user_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_group_member_permission_group_id_permission_group_id_fk": { + "name": "permission_group_member_permission_group_id_permission_group_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "permission_group", + "columnsFrom": ["permission_group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_member_organization_id_organization_id_fk": { + "name": "permission_group_member_organization_id_organization_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_member_user_id_user_id_fk": { + "name": "permission_group_member_user_id_user_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_member_assigned_by_user_id_fk": { + "name": "permission_group_member_assigned_by_user_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "user", + "columnsFrom": ["assigned_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission_group_workspace": { + "name": "permission_group_workspace", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "permission_group_id": { + "name": "permission_group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permission_group_workspace_workspace_id_idx": { + "name": "permission_group_workspace_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_workspace_group_workspace_unique": { + "name": "permission_group_workspace_group_workspace_unique", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_group_workspace_permission_group_id_permission_group_id_fk": { + "name": "permission_group_workspace_permission_group_id_permission_group_id_fk", + "tableFrom": "permission_group_workspace", + "tableTo": "permission_group", + "columnsFrom": ["permission_group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_workspace_workspace_id_workspace_id_fk": { + "name": "permission_group_workspace_workspace_id_workspace_id_fk", + "tableFrom": "permission_group_workspace", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_workspace_organization_id_organization_id_fk": { + "name": "permission_group_workspace_organization_id_organization_id_fk", + "tableFrom": "permission_group_workspace", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permissions": { + "name": "permissions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission_type": { + "name": "permission_type", + "type": "permission_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permissions_user_id_idx": { + "name": "permissions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_entity_idx": { + "name": "permissions_entity_idx", + "columns": [ + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_type_idx": { + "name": "permissions_user_entity_type_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_permission_idx": { + "name": "permissions_user_entity_permission_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "permission_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_idx": { + "name": "permissions_user_entity_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_unique_constraint": { + "name": "permissions_unique_constraint", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permissions_user_id_user_id_fk": { + "name": "permissions_user_id_user_id_fk", + "tableFrom": "permissions", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pinned_item": { + "name": "pinned_item", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pinned_at": { + "name": "pinned_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pinned_item_user_workspace_idx": { + "name": "pinned_item_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pinned_item_resource_idx": { + "name": "pinned_item_resource_idx", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pinned_item_user_resource_unique": { + "name": "pinned_item_user_resource_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pinned_item_user_id_user_id_fk": { + "name": "pinned_item_user_id_user_id_fk", + "tableFrom": "pinned_item", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pinned_item_workspace_id_workspace_id_fk": { + "name": "pinned_item_workspace_id_workspace_id_fk", + "tableFrom": "pinned_item", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.public_share": { + "name": "public_share", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowed_emails": { + "name": "allowed_emails", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "public_share_token_unique": { + "name": "public_share_token_unique", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "public_share_resource_unique": { + "name": "public_share_resource_unique", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "public_share_resource_id_idx": { + "name": "public_share_resource_id_idx", + "columns": [ + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "public_share_workspace_id_idx": { + "name": "public_share_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "public_share_workspace_id_workspace_id_fk": { + "name": "public_share_workspace_id_workspace_id_fk", + "tableFrom": "public_share", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "public_share_created_by_user_id_fk": { + "name": "public_share_created_by_user_id_fk", + "tableFrom": "public_share", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rate_limit_bucket": { + "name": "rate_limit_bucket", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tokens": { + "name": "tokens", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "last_refill_at": { + "name": "last_refill_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.resume_queue": { + "name": "resume_queue", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "paused_execution_id": { + "name": "paused_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_execution_id": { + "name": "parent_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "new_execution_id": { + "name": "new_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "context_id": { + "name": "context_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resume_input": { + "name": "resume_input", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "queued_at": { + "name": "queued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "failure_reason": { + "name": "failure_reason", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "resume_queue_parent_status_idx": { + "name": "resume_queue_parent_status_idx", + "columns": [ + { + "expression": "parent_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "resume_queue_new_execution_idx": { + "name": "resume_queue_new_execution_idx", + "columns": [ + { + "expression": "new_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "resume_queue_paused_execution_id_paused_executions_id_fk": { + "name": "resume_queue_paused_execution_id_paused_executions_id_fk", + "tableFrom": "resume_queue", + "tableTo": "paused_executions", + "columnsFrom": ["paused_execution_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "impersonated_by": { + "name": "impersonated_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "session_user_id_idx": { + "name": "session_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "session_token_idx": { + "name": "session_token_idx", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_active_organization_id_organization_id_fk": { + "name": "session_active_organization_id_organization_id_fk", + "tableFrom": "session", + "tableTo": "organization", + "columnsFrom": ["active_organization_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.settings": { + "name": "settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "theme": { + "name": "theme", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'system'" + }, + "auto_connect": { + "name": "auto_connect", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "telemetry_enabled": { + "name": "telemetry_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "email_preferences": { + "name": "email_preferences", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "billing_usage_notifications_enabled": { + "name": "billing_usage_notifications_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "show_training_controls": { + "name": "show_training_controls", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "super_user_mode_enabled": { + "name": "super_user_mode_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "mothership_environment": { + "name": "mothership_environment", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'default'" + }, + "error_notifications_enabled": { + "name": "error_notifications_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "snap_to_grid_size": { + "name": "snap_to_grid_size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "show_action_bar": { + "name": "show_action_bar", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "copilot_enabled_models": { + "name": "copilot_enabled_models", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "copilot_auto_allowed_tools": { + "name": "copilot_auto_allowed_tools", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "last_active_workspace_id": { + "name": "last_active_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "settings_user_id_user_id_fk": { + "name": "settings_user_id_user_id_fk", + "tableFrom": "settings", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "settings_user_id_unique": { + "name": "settings_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sim_trigger_state": { + "name": "sim_trigger_state", + "schema": "", + "columns": { + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_key": { + "name": "scope_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "last_fired_at": { + "name": "last_fired_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "sim_trigger_state_workflow_id_workflow_id_fk": { + "name": "sim_trigger_state_workflow_id_workflow_id_fk", + "tableFrom": "sim_trigger_state", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "sim_trigger_state_workflow_id_block_id_scope_key_pk": { + "name": "sim_trigger_state_workflow_id_block_id_scope_key_pk", + "columns": ["workflow_id", "block_id", "scope_key"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skill": { + "name": "skill", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "skill_workspace_name_unique": { + "name": "skill_workspace_name_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skill_workspace_id_workspace_id_fk": { + "name": "skill_workspace_id_workspace_id_fk", + "tableFrom": "skill", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "skill_user_id_user_id_fk": { + "name": "skill_user_id_user_id_fk", + "tableFrom": "skill", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skill_member": { + "name": "skill_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "skill_id": { + "name": "skill_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "skill_member_user_id_idx": { + "name": "skill_member_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "skill_member_unique": { + "name": "skill_member_unique", + "columns": [ + { + "expression": "skill_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skill_member_skill_id_skill_id_fk": { + "name": "skill_member_skill_id_skill_id_fk", + "tableFrom": "skill_member", + "tableTo": "skill", + "columnsFrom": ["skill_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "skill_member_user_id_user_id_fk": { + "name": "skill_member_user_id_user_id_fk", + "tableFrom": "skill_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "skill_member_invited_by_user_id_fk": { + "name": "skill_member_invited_by_user_id_fk", + "tableFrom": "skill_member", + "tableTo": "user", + "columnsFrom": ["invited_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sso_domain": { + "name": "sso_domain", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "verification_token": { + "name": "verification_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "verified_at": { + "name": "verified_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sso_domain_organization_id_idx": { + "name": "sso_domain_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_domain_domain_idx": { + "name": "sso_domain_domain_idx", + "columns": [ + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_domain_org_domain_unique": { + "name": "sso_domain_org_domain_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_domain_verified_unique": { + "name": "sso_domain_verified_unique", + "columns": [ + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "status = 'verified'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sso_domain_organization_id_organization_id_fk": { + "name": "sso_domain_organization_id_organization_id_fk", + "tableFrom": "sso_domain", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sso_domain_created_by_user_id_fk": { + "name": "sso_domain_created_by_user_id_fk", + "tableFrom": "sso_domain", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sso_provider": { + "name": "sso_provider", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "oidc_config": { + "name": "oidc_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "saml_config": { + "name": "saml_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "sso_provider_provider_id_idx": { + "name": "sso_provider_provider_id_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_domain_idx": { + "name": "sso_provider_domain_idx", + "columns": [ + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_user_id_idx": { + "name": "sso_provider_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_organization_id_idx": { + "name": "sso_provider_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sso_provider_user_id_user_id_fk": { + "name": "sso_provider_user_id_user_id_fk", + "tableFrom": "sso_provider", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sso_provider_organization_id_organization_id_fk": { + "name": "sso_provider_organization_id_organization_id_fk", + "tableFrom": "sso_provider", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.subscription": { + "name": "subscription", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "period_start": { + "name": "period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "period_end": { + "name": "period_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancel_at_period_end": { + "name": "cancel_at_period_end", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "cancel_at": { + "name": "cancel_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "canceled_at": { + "name": "canceled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "seats": { + "name": "seats", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "trial_start": { + "name": "trial_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trial_end": { + "name": "trial_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "billing_interval": { + "name": "billing_interval", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_schedule_id": { + "name": "stripe_schedule_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "subscription_reference_status_idx": { + "name": "subscription_reference_status_idx", + "columns": [ + { + "expression": "reference_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "check_enterprise_metadata": { + "name": "check_enterprise_metadata", + "value": "plan != 'enterprise' OR metadata IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.table_jobs": { + "name": "table_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "rows_processed": { + "name": "rows_processed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "table_jobs_one_active_per_table": { + "name": "table_jobs_one_active_per_table", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"table_jobs\".\"status\" = 'running' AND \"table_jobs\".\"type\" <> 'export'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_jobs_watchdog_idx": { + "name": "table_jobs_watchdog_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_jobs_table_started_idx": { + "name": "table_jobs_table_started_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_jobs_table_id_user_table_definitions_id_fk": { + "name": "table_jobs_table_id_user_table_definitions_id_fk", + "tableFrom": "table_jobs", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_jobs_workspace_id_workspace_id_fk": { + "name": "table_jobs_workspace_id_workspace_id_fk", + "tableFrom": "table_jobs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.table_row_executions": { + "name": "table_row_executions", + "schema": "", + "columns": { + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "job_id": { + "name": "job_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "running_block_ids": { + "name": "running_block_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "block_errors": { + "name": "block_errors", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "enrichment_details": { + "name": "enrichment_details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "table_row_executions_table_status_idx": { + "name": "table_row_executions_table_status_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"table_row_executions\".\"status\" IN ('queued', 'running', 'pending')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_row_executions_execution_id_idx": { + "name": "table_row_executions_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"table_row_executions\".\"execution_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_row_executions_table_group_idx": { + "name": "table_row_executions_table_group_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_row_executions_table_id_user_table_definitions_id_fk": { + "name": "table_row_executions_table_id_user_table_definitions_id_fk", + "tableFrom": "table_row_executions", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_row_executions_row_id_user_table_rows_id_fk": { + "name": "table_row_executions_row_id_user_table_rows_id_fk", + "tableFrom": "table_row_executions", + "tableTo": "user_table_rows", + "columnsFrom": ["row_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "table_row_executions_row_id_group_id_pk": { + "name": "table_row_executions_row_id_group_id_pk", + "columns": ["row_id", "group_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.table_run_dispatches": { + "name": "table_run_dispatches", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "cursor": { + "name": "cursor", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "limit": { + "name": "limit", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "processed_count": { + "name": "processed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_manual_run": { + "name": "is_manual_run", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "triggered_by_user_id": { + "name": "triggered_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "requested_at": { + "name": "requested_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "table_run_dispatches_active_idx": { + "name": "table_run_dispatches_active_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_run_dispatches_watchdog_idx": { + "name": "table_run_dispatches_watchdog_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "requested_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_run_dispatches_table_id_user_table_definitions_id_fk": { + "name": "table_run_dispatches_table_id_user_table_definitions_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_run_dispatches_workspace_id_workspace_id_fk": { + "name": "table_run_dispatches_workspace_id_workspace_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_run_dispatches_triggered_by_user_id_user_id_fk": { + "name": "table_run_dispatches_triggered_by_user_id_user_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "user", + "columnsFrom": ["triggered_by_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.usage_log": { + "name": "usage_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "usage_log_category", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "usage_log_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "cost": { + "name": "cost", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "event_key": { + "name": "event_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "billing_entity_type": { + "name": "billing_entity_type", + "type": "billing_entity_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "billing_entity_id": { + "name": "billing_entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "billing_period_start": { + "name": "billing_period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "billing_period_end": { + "name": "billing_period_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "usage_log_user_created_at_idx": { + "name": "usage_log_user_created_at_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_source_idx": { + "name": "usage_log_source_idx", + "columns": [ + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_workspace_id_idx": { + "name": "usage_log_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_workflow_id_idx": { + "name": "usage_log_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_event_key_unique": { + "name": "usage_log_event_key_unique", + "columns": [ + { + "expression": "event_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"usage_log\".\"event_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_billing_entity_period_idx": { + "name": "usage_log_billing_entity_period_idx", + "columns": [ + { + "expression": "billing_entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_period_start", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_period_end", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_log\".\"billing_entity_type\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_billing_period_cost_idx": { + "name": "usage_log_billing_period_cost_idx", + "columns": [ + { + "expression": "billing_entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_period_start", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_period_end", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_log\".\"billing_entity_type\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_workspace_created_at_idx": { + "name": "usage_log_workspace_created_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_execution_id_idx": { + "name": "usage_log_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "usage_log_user_id_user_id_fk": { + "name": "usage_log_user_id_user_id_fk", + "tableFrom": "usage_log", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "usage_log_workspace_id_workspace_id_fk": { + "name": "usage_log_workspace_id_workspace_id_fk", + "tableFrom": "usage_log", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "usage_log_workflow_id_workflow_id_fk": { + "name": "usage_log_workflow_id_workflow_id_fk", + "tableFrom": "usage_log", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "usage_log_billing_scope_all_or_none": { + "name": "usage_log_billing_scope_all_or_none", + "value": "(\n (\"usage_log\".\"billing_entity_type\" IS NULL AND \"usage_log\".\"billing_entity_id\" IS NULL AND \"usage_log\".\"billing_period_start\" IS NULL AND \"usage_log\".\"billing_period_end\" IS NULL)\n OR\n (\"usage_log\".\"billing_entity_type\" IS NOT NULL AND \"usage_log\".\"billing_entity_id\" IS NOT NULL AND \"usage_log\".\"billing_period_start\" IS NOT NULL AND \"usage_log\".\"billing_period_end\" IS NOT NULL AND \"usage_log\".\"billing_period_start\" < \"usage_log\".\"billing_period_end\")\n )" + } + }, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "normalized_email": { + "name": "normalized_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'user'" + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + }, + "user_normalized_email_unique": { + "name": "user_normalized_email_unique", + "nullsNotDistinct": false, + "columns": ["normalized_email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_stats": { + "name": "user_stats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "total_manual_executions": { + "name": "total_manual_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_api_calls": { + "name": "total_api_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_webhook_triggers": { + "name": "total_webhook_triggers", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_scheduled_executions": { + "name": "total_scheduled_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_chat_executions": { + "name": "total_chat_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_mcp_executions": { + "name": "total_mcp_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_tokens_used": { + "name": "total_tokens_used", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_cost": { + "name": "total_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_usage_limit": { + "name": "current_usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'5'" + }, + "usage_limit_updated_at": { + "name": "usage_limit_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "current_period_cost": { + "name": "current_period_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "last_period_cost": { + "name": "last_period_cost", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "billed_overage_this_period": { + "name": "billed_overage_this_period", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "pro_period_cost_snapshot": { + "name": "pro_period_cost_snapshot", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "pro_period_cost_snapshot_at": { + "name": "pro_period_cost_snapshot_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "credit_balance": { + "name": "credit_balance", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "total_copilot_cost": { + "name": "total_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_period_copilot_cost": { + "name": "current_period_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "last_period_copilot_cost": { + "name": "last_period_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "total_copilot_tokens": { + "name": "total_copilot_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_copilot_calls": { + "name": "total_copilot_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_mcp_copilot_calls": { + "name": "total_mcp_copilot_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_mcp_copilot_cost": { + "name": "total_mcp_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_period_mcp_copilot_cost": { + "name": "current_period_mcp_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_active": { + "name": "last_active", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "billing_blocked": { + "name": "billing_blocked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "billing_blocked_reason": { + "name": "billing_blocked_reason", + "type": "billing_blocked_reason", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "limit_notifications": { + "name": "limit_notifications", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + } + }, + "indexes": {}, + "foreignKeys": { + "user_stats_user_id_user_id_fk": { + "name": "user_stats_user_id_user_id_fk", + "tableFrom": "user_stats", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_stats_user_id_unique": { + "name": "user_stats_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_table_definitions": { + "name": "user_table_definitions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "schema": { + "name": "schema", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "max_rows": { + "name": "max_rows", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10000 + }, + "row_count": { + "name": "row_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "rows_version": { + "name": "rows_version", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "schema_locked": { + "name": "schema_locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "insert_locked": { + "name": "insert_locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "update_locked": { + "name": "update_locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "delete_locked": { + "name": "delete_locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_table_def_workspace_id_idx": { + "name": "user_table_def_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_folder_id_idx": { + "name": "user_table_def_folder_id_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_workspace_name_unique": { + "name": "user_table_def_workspace_name_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"user_table_definitions\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_archived_at_idx": { + "name": "user_table_def_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_workspace_archived_partial_idx": { + "name": "user_table_def_workspace_archived_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"user_table_definitions\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_table_definitions_workspace_id_workspace_id_fk": { + "name": "user_table_definitions_workspace_id_workspace_id_fk", + "tableFrom": "user_table_definitions", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_table_definitions_folder_id_folder_id_fk": { + "name": "user_table_definitions_folder_id_folder_id_fk", + "tableFrom": "user_table_definitions", + "tableTo": "folder", + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "user_table_definitions_created_by_user_id_fk": { + "name": "user_table_definitions_created_by_user_id_fk", + "tableFrom": "user_table_definitions", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_table_rows": { + "name": "user_table_rows", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "order_key": { + "name": "order_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "user_table_rows_tenant_data_gin_idx": { + "name": "user_table_rows_tenant_data_gin_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"data\" jsonb_path_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "user_table_rows_workspace_table_idx": { + "name": "user_table_rows_workspace_table_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_position_idx": { + "name": "user_table_rows_table_position_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_order_key_idx": { + "name": "user_table_rows_table_order_key_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "order_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_id_id_idx": { + "name": "user_table_rows_table_id_id_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_table_rows_table_id_user_table_definitions_id_fk": { + "name": "user_table_rows_table_id_user_table_definitions_id_fk", + "tableFrom": "user_table_rows", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_table_rows_workspace_id_workspace_id_fk": { + "name": "user_table_rows_workspace_id_workspace_id_fk", + "tableFrom": "user_table_rows", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_table_rows_created_by_user_id_fk": { + "name": "user_table_rows_created_by_user_id_fk", + "tableFrom": "user_table_rows", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "verification_expires_at_idx": { + "name": "verification_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.waitlist": { + "name": "waitlist", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "waitlist_email_unique": { + "name": "waitlist_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhook": { + "name": "webhook", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "registration_status": { + "name": "registration_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "registration_generation": { + "name": "registration_generation", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "config_fingerprint": { + "name": "config_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prepared_at": { + "name": "prepared_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "routing_key": { + "name": "routing_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_config": { + "name": "provider_config", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "failed_count": { + "name": "failed_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_failed_at": { + "name": "last_failed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "path_deployment_unique": { + "name": "path_deployment_unique", + "columns": [ + { + "expression": "path", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"webhook\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_workflow_deployment_idx": { + "name": "webhook_workflow_deployment_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_routing_key_active_idx": { + "name": "webhook_routing_key_active_idx", + "columns": [ + { + "expression": "routing_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"webhook\".\"archived_at\" IS NULL AND \"webhook\".\"routing_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_archived_at_partial_idx": { + "name": "webhook_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"webhook\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_webhook_on_provider_is_active_workflow_id_deploym_bdeed5468": { + "name": "idx_webhook_on_provider_is_active_workflow_id_deploym_bdeed5468", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_tiktok_credential_id_idx": { + "name": "webhook_tiktok_credential_id_idx", + "columns": [ + { + "expression": "((\"provider_config\")::jsonb ->> 'credentialId')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"webhook\".\"provider\" = 'tiktok' AND \"webhook\".\"is_active\" = true AND \"webhook\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_webhook_on_workflow_id_block_id_updated_at_desc": { + "name": "idx_webhook_on_workflow_id_block_id_updated_at_desc", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_active_registration_unique": { + "name": "webhook_active_registration_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"webhook\".\"registration_status\" = 'active' AND \"webhook\".\"block_id\" IS NOT NULL AND \"webhook\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_candidate_registration_unique": { + "name": "webhook_candidate_registration_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"webhook\".\"registration_status\" = 'candidate' AND \"webhook\".\"block_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_registration_status_generation_idx": { + "name": "webhook_registration_status_generation_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "registration_status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "registration_generation", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "webhook_workflow_id_workflow_id_fk": { + "name": "webhook_workflow_id_workflow_id_fk", + "tableFrom": "webhook", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "webhook_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "webhook_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "webhook", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "webhook_registration_status_check": { + "name": "webhook_registration_status_check", + "value": "\"webhook\".\"registration_status\" IS NULL OR \"webhook\".\"registration_status\" IN ('active', 'candidate', 'retired', 'orphaned')" + }, + "webhook_registration_generation_check": { + "name": "webhook_registration_generation_check", + "value": "\"webhook\".\"registration_generation\" IS NULL OR \"webhook\".\"registration_generation\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.webhook_path_claim": { + "name": "webhook_path_claim", + "schema": "", + "columns": { + "path": { + "name": "path", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "webhook_path_claim_workflow_idx": { + "name": "webhook_path_claim_workflow_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "webhook_path_claim_workflow_id_workflow_id_fk": { + "name": "webhook_path_claim_workflow_id_workflow_id_fk", + "tableFrom": "webhook_path_claim", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "webhook_path_claim_generation_check": { + "name": "webhook_path_claim_generation_check", + "value": "\"webhook_path_claim\".\"generation\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.workflow": { + "name": "workflow", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_synced": { + "name": "last_synced", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "is_deployed": { + "name": "is_deployed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deployed_at": { + "name": "deployed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "is_public_api": { + "name": "is_public_api", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "fork_sync_excluded": { + "name": "fork_sync_excluded", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "run_count": { + "name": "run_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workflow_user_id_idx": { + "name": "workflow_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_id_idx": { + "name": "workflow_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_user_workspace_idx": { + "name": "workflow_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_folder_name_active_unique": { + "name": "workflow_workspace_folder_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"folder_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_sort_idx": { + "name": "workflow_folder_sort_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_archived_at_idx": { + "name": "workflow_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_archived_partial_idx": { + "name": "workflow_workspace_archived_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_user_id_user_id_fk": { + "name": "workflow_user_id_user_id_fk", + "tableFrom": "workflow", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_workspace_id_workspace_id_fk": { + "name": "workflow_workspace_id_workspace_id_fk", + "tableFrom": "workflow", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_blocks": { + "name": "workflow_blocks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position_x": { + "name": "position_x", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "position_y": { + "name": "position_y", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "horizontal_handles": { + "name": "horizontal_handles", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "is_wide": { + "name": "is_wide", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "advanced_mode": { + "name": "advanced_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "trigger_mode": { + "name": "trigger_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "height": { + "name": "height", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "sub_blocks": { + "name": "sub_blocks", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "outputs": { + "name": "outputs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_blocks_workflow_id_idx": { + "name": "workflow_blocks_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_blocks_type_idx": { + "name": "workflow_blocks_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_blocks_workflow_id_workflow_id_fk": { + "name": "workflow_blocks_workflow_id_workflow_id_fk", + "tableFrom": "workflow_blocks", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_checkpoints": { + "name": "workflow_checkpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_state": { + "name": "workflow_state", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_checkpoints_user_id_idx": { + "name": "workflow_checkpoints_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_workflow_id_idx": { + "name": "workflow_checkpoints_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_chat_id_idx": { + "name": "workflow_checkpoints_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_message_id_idx": { + "name": "workflow_checkpoints_message_id_idx", + "columns": [ + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_user_workflow_idx": { + "name": "workflow_checkpoints_user_workflow_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_workflow_chat_idx": { + "name": "workflow_checkpoints_workflow_chat_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_created_at_idx": { + "name": "workflow_checkpoints_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_chat_created_at_idx": { + "name": "workflow_checkpoints_chat_created_at_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_checkpoints_user_id_user_id_fk": { + "name": "workflow_checkpoints_user_id_user_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_checkpoints_workflow_id_workflow_id_fk": { + "name": "workflow_checkpoints_workflow_id_workflow_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_checkpoints_chat_id_copilot_chats_id_fk": { + "name": "workflow_checkpoints_chat_id_copilot_chats_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_deployment_operation": { + "name": "workflow_deployment_operation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "previous_active_version_id": { + "name": "previous_active_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "protocol_version": { + "name": "protocol_version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'preparing'" + }, + "component_readiness": { + "name": "component_readiness", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "request_hash": { + "name": "request_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_deployment_operation_workflow_generation_unique": { + "name": "workflow_deployment_operation_workflow_generation_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "generation", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_idempotency_unique": { + "name": "workflow_deployment_operation_workflow_idempotency_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_deployment_operation\".\"idempotency_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_in_flight_unique": { + "name": "workflow_deployment_operation_workflow_in_flight_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_deployment_operation\".\"status\" IN ('preparing', 'activating')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_status_idx": { + "name": "workflow_deployment_operation_workflow_status_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_deployment_version_idx": { + "name": "workflow_deployment_operation_deployment_version_idx", + "columns": [ + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_version_generation_idx": { + "name": "workflow_deployment_operation_workflow_version_generation_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "generation", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_deployment_operation_workflow_id_workflow_id_fk": { + "name": "workflow_deployment_operation_workflow_id_workflow_id_fk", + "tableFrom": "workflow_deployment_operation", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_deployment_operation_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_deployment_operation_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_deployment_operation", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_deployment_operation_previous_active_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_deployment_operation_previous_active_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_deployment_operation", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["previous_active_version_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "workflow_deployment_operation_action_check": { + "name": "workflow_deployment_operation_action_check", + "value": "\"workflow_deployment_operation\".\"action\" IN ('deploy', 'activate')" + }, + "workflow_deployment_operation_status_check": { + "name": "workflow_deployment_operation_status_check", + "value": "\"workflow_deployment_operation\".\"status\" IN ('preparing', 'activating', 'active', 'failed', 'superseded')" + }, + "workflow_deployment_operation_generation_check": { + "name": "workflow_deployment_operation_generation_check", + "value": "\"workflow_deployment_operation\".\"generation\" > 0" + }, + "workflow_deployment_operation_protocol_version_check": { + "name": "workflow_deployment_operation_protocol_version_check", + "value": "\"workflow_deployment_operation\".\"protocol_version\" > 0" + } + }, + "isRLSEnabled": false + }, + "public.workflow_deployment_version": { + "name": "workflow_deployment_version", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workflow_deployment_version_workflow_version_unique": { + "name": "workflow_deployment_version_workflow_version_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_version_workflow_active_idx": { + "name": "workflow_deployment_version_workflow_active_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_version_created_at_idx": { + "name": "workflow_deployment_version_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_deployment_version_workflow_id_workflow_id_fk": { + "name": "workflow_deployment_version_workflow_id_workflow_id_fk", + "tableFrom": "workflow_deployment_version", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_edges": { + "name": "workflow_edges", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_block_id": { + "name": "source_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_block_id": { + "name": "target_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_handle": { + "name": "source_handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_handle": { + "name": "target_handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_edges_workflow_id_idx": { + "name": "workflow_edges_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_edges_workflow_source_idx": { + "name": "workflow_edges_workflow_source_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_edges_workflow_target_idx": { + "name": "workflow_edges_workflow_target_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_edges_workflow_id_workflow_id_fk": { + "name": "workflow_edges_workflow_id_workflow_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_edges_source_block_id_workflow_blocks_id_fk": { + "name": "workflow_edges_source_block_id_workflow_blocks_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow_blocks", + "columnsFrom": ["source_block_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_edges_target_block_id_workflow_blocks_id_fk": { + "name": "workflow_edges_target_block_id_workflow_blocks_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow_blocks", + "columnsFrom": ["target_block_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_execution_logs": { + "name": "workflow_execution_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state_snapshot_id": { + "name": "state_snapshot_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_duration_ms": { + "name": "total_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "execution_data": { + "name": "execution_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "cost": { + "name": "cost", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "cost_total": { + "name": "cost_total", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "models_used": { + "name": "models_used", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "files": { + "name": "files", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_execution_logs_workflow_id_idx": { + "name": "workflow_execution_logs_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_state_snapshot_id_idx": { + "name": "workflow_execution_logs_state_snapshot_id_idx", + "columns": [ + { + "expression": "state_snapshot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_deployment_version_id_idx": { + "name": "workflow_execution_logs_deployment_version_id_idx", + "columns": [ + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_trigger_idx": { + "name": "workflow_execution_logs_trigger_idx", + "columns": [ + { + "expression": "trigger", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_level_idx": { + "name": "workflow_execution_logs_level_idx", + "columns": [ + { + "expression": "level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_started_at_idx": { + "name": "workflow_execution_logs_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_execution_id_unique": { + "name": "workflow_execution_logs_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workflow_started_at_idx": { + "name": "workflow_execution_logs_workflow_started_at_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workspace_started_at_idx": { + "name": "workflow_execution_logs_workspace_started_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workspace_started_at_id_desc_idx": { + "name": "workflow_execution_logs_workspace_started_at_id_desc_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"started_at\" DESC NULLS LAST", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "\"id\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workspace_cost_total_idx": { + "name": "workflow_execution_logs_workspace_cost_total_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_total", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_models_used_idx": { + "name": "workflow_execution_logs_models_used_idx", + "columns": [ + { + "expression": "models_used", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "workflow_execution_logs_workspace_ended_at_id_idx": { + "name": "workflow_execution_logs_workspace_ended_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"ended_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_running_started_at_idx": { + "name": "workflow_execution_logs_running_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "status = 'running'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_completed_ended_at_idx": { + "name": "workflow_execution_logs_completed_ended_at_idx", + "columns": [ + { + "expression": "ended_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_execution_logs\".\"status\" = 'completed' AND \"workflow_execution_logs\".\"level\" = 'info' AND \"workflow_execution_logs\".\"ended_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_execution_logs_workflow_id_workflow_id_fk": { + "name": "workflow_execution_logs_workflow_id_workflow_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workflow_execution_logs_workspace_id_workspace_id_fk": { + "name": "workflow_execution_logs_workspace_id_workspace_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_execution_logs_state_snapshot_id_workflow_execution_snapshots_id_fk": { + "name": "workflow_execution_logs_state_snapshot_id_workflow_execution_snapshots_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow_execution_snapshots", + "columnsFrom": ["state_snapshot_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "workflow_execution_logs_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_execution_logs_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_execution_snapshots": { + "name": "workflow_execution_snapshots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state_hash": { + "name": "state_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state_data": { + "name": "state_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_snapshots_workflow_id_idx": { + "name": "workflow_snapshots_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_hash_idx": { + "name": "workflow_snapshots_hash_idx", + "columns": [ + { + "expression": "state_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_workflow_hash_idx": { + "name": "workflow_snapshots_workflow_hash_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "state_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_created_at_idx": { + "name": "workflow_snapshots_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_execution_snapshots_workflow_id_workflow_id_fk": { + "name": "workflow_execution_snapshots_workflow_id_workflow_id_fk", + "tableFrom": "workflow_execution_snapshots", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_folder": { + "name": "workflow_folder", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'#6B7280'" + }, + "is_expanded": { + "name": "is_expanded", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workflow_folder_user_idx": { + "name": "workflow_folder_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_workspace_parent_idx": { + "name": "workflow_folder_workspace_parent_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_parent_sort_idx": { + "name": "workflow_folder_parent_sort_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_archived_at_idx": { + "name": "workflow_folder_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_workspace_archived_partial_idx": { + "name": "workflow_folder_workspace_archived_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_folder\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_folder_user_id_user_id_fk": { + "name": "workflow_folder_user_id_user_id_fk", + "tableFrom": "workflow_folder", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_folder_workspace_id_workspace_id_fk": { + "name": "workflow_folder_workspace_id_workspace_id_fk", + "tableFrom": "workflow_folder", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_mcp_server": { + "name": "workflow_mcp_server", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_mcp_server_workspace_id_idx": { + "name": "workflow_mcp_server_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_server_created_by_idx": { + "name": "workflow_mcp_server_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_server_deleted_at_idx": { + "name": "workflow_mcp_server_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_server_workspace_deleted_partial_idx": { + "name": "workflow_mcp_server_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_mcp_server\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_mcp_server_workspace_id_workspace_id_fk": { + "name": "workflow_mcp_server_workspace_id_workspace_id_fk", + "tableFrom": "workflow_mcp_server", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_mcp_server_created_by_user_id_fk": { + "name": "workflow_mcp_server_created_by_user_id_fk", + "tableFrom": "workflow_mcp_server", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_mcp_tool": { + "name": "workflow_mcp_tool", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_description": { + "name": "tool_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parameter_schema": { + "name": "parameter_schema", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "parameter_description_overrides": { + "name": "parameter_description_overrides", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'::json" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_mcp_tool_server_id_idx": { + "name": "workflow_mcp_tool_server_id_idx", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_tool_workflow_id_idx": { + "name": "workflow_mcp_tool_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_tool_server_workflow_unique": { + "name": "workflow_mcp_tool_server_workflow_unique", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_mcp_tool\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_tool_archived_at_partial_idx": { + "name": "workflow_mcp_tool_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_mcp_tool\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_mcp_tool_server_id_workflow_mcp_server_id_fk": { + "name": "workflow_mcp_tool_server_id_workflow_mcp_server_id_fk", + "tableFrom": "workflow_mcp_tool", + "tableTo": "workflow_mcp_server", + "columnsFrom": ["server_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_mcp_tool_workflow_id_workflow_id_fk": { + "name": "workflow_mcp_tool_workflow_id_workflow_id_fk", + "tableFrom": "workflow_mcp_tool", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_schedule": { + "name": "workflow_schedule", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deployment_operation_id": { + "name": "deployment_operation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cron_expression": { + "name": "cron_expression", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_run_at": { + "name": "next_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_ran_at": { + "name": "last_ran_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_queued_at": { + "name": "last_queued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trigger_type": { + "name": "trigger_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'UTC'" + }, + "failed_count": { + "name": "failed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "infra_retry_count": { + "name": "infra_retry_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "last_failed_at": { + "name": "last_failed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "source_type": { + "name": "source_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'workflow'" + }, + "job_title": { + "name": "job_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lifecycle": { + "name": "lifecycle", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'persistent'" + }, + "success_condition": { + "name": "success_condition", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "max_runs": { + "name": "max_runs", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "run_count": { + "name": "run_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "source_chat_id": { + "name": "source_chat_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_task_name": { + "name": "source_task_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_user_id": { + "name": "source_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_workspace_id": { + "name": "source_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "job_history": { + "name": "job_history", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "contexts": { + "name": "contexts", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "excluded_dates": { + "name": "excluded_dates", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "ends_at": { + "name": "ends_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_schedule_workflow_block_deployment_unique": { + "name": "workflow_schedule_workflow_block_deployment_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_schedule\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_workflow_deployment_idx": { + "name": "workflow_schedule_workflow_deployment_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_archived_at_partial_idx": { + "name": "workflow_schedule_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_schedule\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workflow_schedule_on_source_workspace_id_source_t_c07f3bba6": { + "name": "idx_workflow_schedule_on_source_workspace_id_source_t_c07f3bba6", + "columns": [ + { + "expression": "source_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_due_workflow_idx": { + "name": "workflow_schedule_due_workflow_idx", + "columns": [ + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_schedule\".\"archived_at\" IS NULL AND \"workflow_schedule\".\"status\" NOT IN ('disabled', 'completed') AND (\"workflow_schedule\".\"source_type\" = 'workflow' OR \"workflow_schedule\".\"source_type\" IS NULL)", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_due_job_idx": { + "name": "workflow_schedule_due_job_idx", + "columns": [ + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_schedule\".\"archived_at\" IS NULL AND \"workflow_schedule\".\"status\" NOT IN ('disabled', 'completed') AND \"workflow_schedule\".\"source_type\" = 'job'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_schedule_workflow_id_workflow_id_fk": { + "name": "workflow_schedule_workflow_id_workflow_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_schedule_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_deployment_operation_id_workflow_deployment_operation_id_fk": { + "name": "workflow_schedule_deployment_operation_id_workflow_deployment_operation_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow_deployment_operation", + "columnsFrom": ["deployment_operation_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workflow_schedule_source_user_id_user_id_fk": { + "name": "workflow_schedule_source_user_id_user_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "user", + "columnsFrom": ["source_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_source_workspace_id_workspace_id_fk": { + "name": "workflow_schedule_source_workspace_id_workspace_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workspace", + "columnsFrom": ["source_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_subflows": { + "name": "workflow_subflows", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_subflows_workflow_id_idx": { + "name": "workflow_subflows_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_subflows_workflow_type_idx": { + "name": "workflow_subflows_workflow_type_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_subflows_workflow_id_workflow_id_fk": { + "name": "workflow_subflows_workflow_id_workflow_id_fk", + "tableFrom": "workflow_subflows", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace": { + "name": "workspace", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'#33C482'" + }, + "logo_url": { + "name": "logo_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_mode": { + "name": "workspace_mode", + "type": "workspace_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'grandfathered_shared'" + }, + "billed_account_user_id": { + "name": "billed_account_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "allow_personal_api_keys": { + "name": "allow_personal_api_keys", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "inbox_enabled": { + "name": "inbox_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "inbox_address": { + "name": "inbox_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "inbox_provider_id": { + "name": "inbox_provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "organization_assigned_at": { + "name": "organization_assigned_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "forked_from_workspace_id": { + "name": "forked_from_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_owner_id_idx": { + "name": "workspace_owner_id_idx", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_organization_id_idx": { + "name": "workspace_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_mode_idx": { + "name": "workspace_mode_idx", + "columns": [ + { + "expression": "workspace_mode", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_forked_from_workspace_id_idx": { + "name": "workspace_forked_from_workspace_id_idx", + "columns": [ + { + "expression": "forked_from_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_owner_id_user_id_fk": { + "name": "workspace_owner_id_user_id_fk", + "tableFrom": "workspace", + "tableTo": "user", + "columnsFrom": ["owner_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_organization_id_organization_id_fk": { + "name": "workspace_organization_id_organization_id_fk", + "tableFrom": "workspace", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspace_billed_account_user_id_user_id_fk": { + "name": "workspace_billed_account_user_id_user_id_fk", + "tableFrom": "workspace", + "tableTo": "user", + "columnsFrom": ["billed_account_user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "workspace_forked_from_workspace_id_workspace_id_fk": { + "name": "workspace_forked_from_workspace_id_workspace_id_fk", + "tableFrom": "workspace", + "tableTo": "workspace", + "columnsFrom": ["forked_from_workspace_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "workspace_storage_used_bytes_non_negative": { + "name": "workspace_storage_used_bytes_non_negative", + "value": "\"workspace\".\"storage_used_bytes\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.workspace_byok_keys": { + "name": "workspace_byok_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_api_key": { + "name": "encrypted_api_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_byok_workspace_provider_idx": { + "name": "workspace_byok_workspace_provider_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_byok_keys_workspace_id_workspace_id_fk": { + "name": "workspace_byok_keys_workspace_id_workspace_id_fk", + "tableFrom": "workspace_byok_keys", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_byok_keys_created_by_user_id_fk": { + "name": "workspace_byok_keys_created_by_user_id_fk", + "tableFrom": "workspace_byok_keys", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_environment": { + "name": "workspace_environment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_environment_workspace_unique": { + "name": "workspace_environment_workspace_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_environment_workspace_id_workspace_id_fk": { + "name": "workspace_environment_workspace_id_workspace_id_fk", + "tableFrom": "workspace_environment", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file": { + "name": "workspace_file", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_file_workspace_id_idx": { + "name": "workspace_file_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_key_idx": { + "name": "workspace_file_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_deleted_at_idx": { + "name": "workspace_file_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_workspace_deleted_partial_idx": { + "name": "workspace_file_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_file\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_file_workspace_id_workspace_id_fk": { + "name": "workspace_file_workspace_id_workspace_id_fk", + "tableFrom": "workspace_file", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_file_uploaded_by_user_id_fk": { + "name": "workspace_file_uploaded_by_user_id_fk", + "tableFrom": "workspace_file", + "tableTo": "user", + "columnsFrom": ["uploaded_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workspace_file_key_unique": { + "name": "workspace_file_key_unique", + "nullsNotDistinct": false, + "columns": ["key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file_folders": { + "name": "workspace_file_folders", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_file_folders_workspace_parent_idx": { + "name": "workspace_file_folders_workspace_parent_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_folders_parent_sort_idx": { + "name": "workspace_file_folders_parent_sort_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_folders_deleted_at_idx": { + "name": "workspace_file_folders_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_folders_workspace_deleted_partial_idx": { + "name": "workspace_file_folders_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_file_folders\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_folders_workspace_parent_name_active_unique": { + "name": "workspace_file_folders_workspace_parent_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"parent_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_file_folders\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_file_folders_user_id_user_id_fk": { + "name": "workspace_file_folders_user_id_user_id_fk", + "tableFrom": "workspace_file_folders", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_file_folders_workspace_id_workspace_id_fk": { + "name": "workspace_file_folders_workspace_id_workspace_id_fk", + "tableFrom": "workspace_file_folders", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_file_folders_parent_id_workspace_file_folders_id_fk": { + "name": "workspace_file_folders_parent_id_workspace_file_folders_id_fk", + "tableFrom": "workspace_file_folders", + "tableTo": "workspace_file_folders", + "columnsFrom": ["parent_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_files": { + "name": "workspace_files", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "context": { + "name": "context", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "original_name": { + "name": "original_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_files_key_active_unique": { + "name": "workspace_files_key_active_unique", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_files\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_workspace_folder_name_active_unique": { + "name": "workspace_files_workspace_folder_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"folder_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "original_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_files\".\"deleted_at\" IS NULL AND \"workspace_files\".\"context\" = 'workspace' AND \"workspace_files\".\"workspace_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_chat_display_name_unique": { + "name": "workspace_files_chat_display_name_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "display_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_files\".\"context\" = 'mothership' AND \"workspace_files\".\"chat_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_key_idx": { + "name": "workspace_files_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_user_id_idx": { + "name": "workspace_files_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_workspace_id_idx": { + "name": "workspace_files_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_folder_id_idx": { + "name": "workspace_files_folder_id_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_context_idx": { + "name": "workspace_files_context_idx", + "columns": [ + { + "expression": "context", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_chat_id_idx": { + "name": "workspace_files_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_deleted_at_idx": { + "name": "workspace_files_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_workspace_deleted_partial_idx": { + "name": "workspace_files_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_files\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_files_user_id_user_id_fk": { + "name": "workspace_files_user_id_user_id_fk", + "tableFrom": "workspace_files", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_files_workspace_id_workspace_id_fk": { + "name": "workspace_files_workspace_id_workspace_id_fk", + "tableFrom": "workspace_files", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_files_chat_id_copilot_chats_id_fk": { + "name": "workspace_files_chat_id_copilot_chats_id_fk", + "tableFrom": "workspace_files", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_block_map": { + "name": "workspace_fork_block_map", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_workflow_id": { + "name": "parent_workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_block_id": { + "name": "parent_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_workflow_id": { + "name": "child_workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_block_id": { + "name": "child_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_block_map_child_ws_parent_unique": { + "name": "workspace_fork_block_map_child_ws_parent_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_block_map_child_ws_child_unique": { + "name": "workspace_fork_block_map_child_ws_child_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "child_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_block_map_child_ws_parent_wf_idx": { + "name": "workspace_fork_block_map_child_ws_parent_wf_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_block_map_child_ws_child_wf_idx": { + "name": "workspace_fork_block_map_child_ws_child_wf_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "child_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_block_map_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_block_map_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_block_map", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_dependent_value": { + "name": "workspace_fork_dependent_value", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_workflow_id": { + "name": "target_workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_block_id": { + "name": "target_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sub_block_key": { + "name": "sub_block_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_dependent_value_child_ws_wf_idx": { + "name": "workspace_fork_dependent_value_child_ws_wf_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_dependent_value_field_unique": { + "name": "workspace_fork_dependent_value_field_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sub_block_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_dependent_value_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_dependent_value_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_dependent_value", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_promote_run": { + "name": "workspace_fork_promote_run", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_workspace_id": { + "name": "source_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_workspace_id": { + "name": "target_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "direction": { + "name": "direction", + "type": "workspace_fork_promote_direction", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "snapshot": { + "name": "snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_promote_run_child_ws_target_unique": { + "name": "workspace_fork_promote_run_child_ws_target_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_promote_run_target_ws_idx": { + "name": "workspace_fork_promote_run_target_ws_idx", + "columns": [ + { + "expression": "target_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_promote_run_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_promote_run_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_promote_run", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_fork_promote_run_created_by_user_id_fk": { + "name": "workspace_fork_promote_run_created_by_user_id_fk", + "tableFrom": "workspace_fork_promote_run", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_resource_map": { + "name": "workspace_fork_resource_map", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "workspace_fork_resource_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "parent_resource_id": { + "name": "parent_resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_resource_id": { + "name": "child_resource_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_resource_map_child_ws_idx": { + "name": "workspace_fork_resource_map_child_ws_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_resource_map_child_ws_type_idx": { + "name": "workspace_fork_resource_map_child_ws_type_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_resource_map_child_type_parent_unique": { + "name": "workspace_fork_resource_map_child_type_parent_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_resource_map_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_resource_map_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_resource_map", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_fork_resource_map_created_by_user_id_fk": { + "name": "workspace_fork_resource_map_created_by_user_id_fk", + "tableFrom": "workspace_fork_resource_map", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.academy_cert_status": { + "name": "academy_cert_status", + "schema": "public", + "values": ["active", "revoked", "expired"] + }, + "public.background_work_kind": { + "name": "background_work_kind", + "schema": "public", + "values": ["deployment_side_effects", "fork_content_copy", "fork_sync", "fork_rollback"] + }, + "public.background_work_status_value": { + "name": "background_work_status_value", + "schema": "public", + "values": ["pending", "processing", "completed", "completed_with_warnings", "failed"] + }, + "public.billing_blocked_reason": { + "name": "billing_blocked_reason", + "schema": "public", + "values": ["payment_failed", "dispute"] + }, + "public.billing_entity_type": { + "name": "billing_entity_type", + "schema": "public", + "values": ["user", "organization"] + }, + "public.chat_type": { + "name": "chat_type", + "schema": "public", + "values": ["mothership", "copilot"] + }, + "public.copilot_async_tool_status": { + "name": "copilot_async_tool_status", + "schema": "public", + "values": ["pending", "running", "completed", "failed", "cancelled", "delivered"] + }, + "public.copilot_run_status": { + "name": "copilot_run_status", + "schema": "public", + "values": ["active", "paused_waiting_for_tool", "resuming", "complete", "error", "cancelled"] + }, + "public.copilot_tool_permission_decision": { + "name": "copilot_tool_permission_decision", + "schema": "public", + "values": ["allow", "allow_chat", "always_allow", "skip"] + }, + "public.credential_member_role": { + "name": "credential_member_role", + "schema": "public", + "values": ["admin", "member"] + }, + "public.credential_member_status": { + "name": "credential_member_status", + "schema": "public", + "values": ["active", "pending", "revoked"] + }, + "public.credential_type": { + "name": "credential_type", + "schema": "public", + "values": ["oauth", "env_workspace", "env_personal", "service_account"] + }, + "public.data_drain_cadence": { + "name": "data_drain_cadence", + "schema": "public", + "values": ["hourly", "daily"] + }, + "public.data_drain_destination": { + "name": "data_drain_destination", + "schema": "public", + "values": ["s3", "gcs", "azure_blob", "datadog", "bigquery", "snowflake", "webhook"] + }, + "public.data_drain_run_status": { + "name": "data_drain_run_status", + "schema": "public", + "values": ["running", "success", "failed"] + }, + "public.data_drain_run_trigger": { + "name": "data_drain_run_trigger", + "schema": "public", + "values": ["cron", "manual"] + }, + "public.data_drain_source": { + "name": "data_drain_source", + "schema": "public", + "values": ["workflow_logs", "job_logs", "audit_logs", "copilot_chats", "copilot_runs"] + }, + "public.execution_large_value_reference_source": { + "name": "execution_large_value_reference_source", + "schema": "public", + "values": ["execution_log", "paused_snapshot"] + }, + "public.folder_resource_type": { + "name": "folder_resource_type", + "schema": "public", + "values": ["workflow", "file", "knowledge_base", "table"] + }, + "public.invitation_kind": { + "name": "invitation_kind", + "schema": "public", + "values": ["organization", "workspace"] + }, + "public.invitation_membership_intent": { + "name": "invitation_membership_intent", + "schema": "public", + "values": ["internal", "external"] + }, + "public.invitation_status": { + "name": "invitation_status", + "schema": "public", + "values": ["pending", "accepted", "rejected", "cancelled", "expired"] + }, + "public.permission_type": { + "name": "permission_type", + "schema": "public", + "values": ["admin", "write", "read"] + }, + "public.usage_log_category": { + "name": "usage_log_category", + "schema": "public", + "values": ["model", "fixed", "tool"] + }, + "public.usage_log_source": { + "name": "usage_log_source", + "schema": "public", + "values": [ + "workflow", + "wand", + "copilot", + "workspace-chat", + "mcp_copilot", + "mothership_block", + "knowledge-base", + "voice-input", + "enrichment" + ] + }, + "public.workspace_fork_promote_direction": { + "name": "workspace_fork_promote_direction", + "schema": "public", + "values": ["push", "pull"] + }, + "public.workspace_fork_resource_type": { + "name": "workspace_fork_resource_type", + "schema": "public", + "values": [ + "workflow", + "oauth_credential", + "service_account_credential", + "env_var", + "table", + "knowledge_base", + "knowledge_document", + "file", + "mcp_server", + "workflow_mcp_server", + "custom_tool", + "skill" + ] + }, + "public.workspace_mode": { + "name": "workspace_mode", + "schema": "public", + "values": ["personal", "organization", "grandfathered_shared"] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/db/migrations/meta/_journal.json b/packages/db/migrations/meta/_journal.json index aad8eca6e30..eea606ba98c 100644 --- a/packages/db/migrations/meta/_journal.json +++ b/packages/db/migrations/meta/_journal.json @@ -1917,7 +1917,7 @@ "idx": 274, "version": "7", "when": 1785281897202, - "tag": "0274_file_folder_cutover_catchup_backfill", + "tag": "0274_file_folder_cutover_reconcile", "breakpoints": true } ] diff --git a/packages/db/schema.ts b/packages/db/schema.ts index 1504fe9067a..e0bf19321e4 100644 --- a/packages/db/schema.ts +++ b/packages/db/schema.ts @@ -1912,9 +1912,11 @@ export const workspaceFile = pgTable( * cover folders created after it ran (migration 0274 catches those up). * * Do NOT drop it in a contract migration until BOTH hold: the cutover has been running in - * production long enough that a rollback is off the table, and a row-count comparison - * against `folder WHERE resource_type = 'file'` confirms nothing was stranded. Dropping it - * on the strength of "no code references it" alone destroys the only rollback path. + * production long enough that a rollback is off the table, and a FULL-ROW comparison against + * `folder WHERE resource_type = 'file'` confirms nothing was stranded. A row COUNT is not + * sufficient — the pre-0274 divergence was in row contents (names, parents, `deleted_at`), + * which a count check passes straight through. Dropping this on the strength of "no code + * references it" alone destroys the only rollback path. */ export const workspaceFileFolder = pgTable( 'workspace_file_folders', From e0eea45ad071559845e71b3bea30df6ce629089e Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 28 Jul 2026 21:03:23 -0700 Subject: [PATCH 05/11] fix(folders): address the review round and the full-PR audit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reverts the workflow restore hook — it was the wrong fix - Greptile flagged that restoring a folder leaves schedules disabled and webhooks/chats inactive. The hook added last round routed workflows through `restoreWorkflow` to address it, but `restoreDependents` ALREADY clears exactly those columns, in bulk, inside the restore transaction and matched on the archive timestamp. The hook bought nothing and cost a per-workflow read/transaction/read OUTSIDE that transaction — roughly 1600 round trips for a folder of 200 workflows — plus a window where the workflows were active and the folder was not, and it made `restoreDependents` dead code - What no restore path can undo is the state archive OVERWRITES: `status: 'disabled'` with `nextRunAt` cleared, `isActive: false`. Archive does not record what those were, so restoring them to a constant would re-enable a schedule the user had disabled and re-run a completed one. Left explicit — redeploy re-activates a schedule — matching deployment state, which restore also deliberately leaves off. Documented on the config rather than guessed - Kept the genuine bug the review surfaced underneath it: `restoreWorkflow` un-archived every dependent by `workflowId` alone, resurrecting a webhook or chat the user had archived days earlier. Now matched on the workflow's own `archivedAt`, which is the semantics this feature documents Bugbot: orphaned knowledge bases vanished from every view - Knowledge filtered on a raw `folderId === currentFolderId` and never re-rooted a base whose folder is missing or archived, so a base restored on its own — or left behind by a partial cascade — was invisible everywhere. Tables already fell those back to the root A dead `?folderId=` is now healed instead of being a dead end - A bookmark to a deleted folder rendered the root title over an empty grid, hid everything actually at the root, and — worse — left every create/upload action targeting the dead id, filing new resources somewhere nothing could reach. `useFolderNavigation` clears it once the folder list resolves, which fixes Files, Knowledge, and Tables at once Parity gaps found by auditing against Files - Tables sorted folders newest-first on a clean URL while Files and Knowledge sorted A→Z: its sort params are defaulted, so the raw column was never null. Reads `activeSort` now - On Tables you could not delete the folder you were standing in — its own row is not in the list, and the crumb menu offered only Rename, which also made the step-out branch unreachable dead code - A failed knowledge-base move was logged and never surfaced, so a rejected move — from the submenu or from a drag — looked like nothing happened - A drag begun in another mount of the page could never be dropped: `onDragOver` bailed before `preventDefault`, so no drop event ever fired and the `dataTransfer` fallback in `onDrop` was unreachable. External/foreign drags are still ignored, so an OS file dropped on the list cannot navigate the tab away Files: a folder cycle crashed the page - The folder-size roll-up recursed with no `visited` guard, so a parent/child cycle in the cached tree — reachable through the optimistic folder-move write — recursed until the stack blew and the page went blank. Also indexes children once instead of re-scanning per node Migration 0274 is now atomic - 0272 ends with an embedded COMMIT for its CONCURRENTLY index builds, so this file is not guaranteed to run inside drizzle's batch transaction. Wrapped in a DO block so the parking pass cannot commit without the reconcile, which would leave every mirrored folder holding a placeholder name - Validated with a read-only dry run: no active name duplicates, no orphaned or cross-workspace parents, and no id collisions with `workflow_folder`, so the insert cannot trip the unique index, the FKs, or the resource-type trigger Smaller corrections - Knowledge indexed its members for the owner comparator instead of two linear scans per comparison, and logs a load failure from an effect rather than the render body - Files uses the shared root sentinel instead of a bare `'__root__'` in two places - `restore_resource` documents that its two new folder types are unreachable until the enum in the copilot service's tool catalog — a different repository — widens - Corrected a forking comment that still described file folders as a separate table --- .../sim/app/api/table/[tableId]/route.test.ts | 9 +- apps/sim/app/api/table/[tableId]/route.ts | 8 +- .../folders/use-folder-navigation.ts | 19 +++- .../folders/use-folder-row-drag-drop.ts | 16 +++- .../workspace/[workspaceId]/files/files.tsx | 36 ++++++-- .../[workspaceId]/knowledge/knowledge.tsx | 58 ++++++++++--- .../workspace/[workspaceId]/tables/tables.tsx | 41 +++++++-- .../lib/copy/copy-workflows.ts | 5 +- apps/sim/lib/api/contracts/folders.ts | 2 +- apps/sim/lib/folders/config.ts | 48 ++++------ .../orchestration/restore-resource.ts | 6 ++ apps/sim/lib/table/service.ts | 17 +++- apps/sim/lib/workflows/lifecycle.ts | 35 +++++--- .../0274_file_folder_cutover_reconcile.sql | 87 ++++++++++--------- 14 files changed, 268 insertions(+), 119 deletions(-) diff --git a/apps/sim/app/api/table/[tableId]/route.test.ts b/apps/sim/app/api/table/[tableId]/route.test.ts index 633d52ebd10..7f1f48243c7 100644 --- a/apps/sim/app/api/table/[tableId]/route.test.ts +++ b/apps/sim/app/api/table/[tableId]/route.test.ts @@ -97,6 +97,7 @@ describe('PATCH /api/table/[tableId] folder moves', () => { expect(mockFindActiveFolder).toHaveBeenCalledWith('folder-1', 'workspace-1', 'table') expect(mockMoveTableToFolder).toHaveBeenCalledWith( 'tbl_1', + 'workspace-1', 'folder-1', expect.any(String), 'user-1' @@ -113,7 +114,13 @@ describe('PATCH /api/table/[tableId] folder moves', () => { expect(response.status).toBe(200) expect(mockFindActiveFolder).not.toHaveBeenCalled() - expect(mockMoveTableToFolder).toHaveBeenCalledWith('tbl_1', null, expect.any(String), 'user-1') + expect(mockMoveTableToFolder).toHaveBeenCalledWith( + 'tbl_1', + 'workspace-1', + null, + expect.any(String), + 'user-1' + ) }) it('leaves placement untouched when folderId is omitted', async () => { diff --git a/apps/sim/app/api/table/[tableId]/route.ts b/apps/sim/app/api/table/[tableId]/route.ts index d1f084fd1d8..b6b91bde24e 100644 --- a/apps/sim/app/api/table/[tableId]/route.ts +++ b/apps/sim/app/api/table/[tableId]/route.ts @@ -196,7 +196,13 @@ export const PATCH = withRouteHandler( ) { return NextResponse.json({ error: 'Folder not found in this workspace' }, { status: 404 }) } - await moveTableToFolder(tableId, validated.folderId, requestId, authResult.userId) + await moveTableToFolder( + tableId, + table.workspaceId, + validated.folderId, + requestId, + authResult.userId + ) } // Re-read so the response reflects both a rename and a lock change. diff --git a/apps/sim/app/workspace/[workspaceId]/components/folders/use-folder-navigation.ts b/apps/sim/app/workspace/[workspaceId]/components/folders/use-folder-navigation.ts index 69628370e6d..d6005250adb 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/folders/use-folder-navigation.ts +++ b/apps/sim/app/workspace/[workspaceId]/components/folders/use-folder-navigation.ts @@ -1,6 +1,6 @@ 'use client' -import { useCallback, useMemo } from 'react' +import { useCallback, useEffect, useMemo } from 'react' import { useQueryStates } from 'nuqs' import type { ServedFolderResourceType } from '@/lib/api/contracts/folders' import { @@ -67,6 +67,23 @@ export function useFolderNavigation({ return byId }, [folders]) + /** + * Heals a `?folderId=` that no longer resolves — a bookmark to a folder since deleted, or a + * link from someone whose workspace it was not. + * + * Without this the page is a dead end rather than a mistake: the header falls back to the + * root title while the list still filters on the dead id, so the user sees a page that looks + * like the root but is empty and hides everything actually at the root. Worse, the create + * and upload actions keep targeting that id, so a new resource is filed somewhere nothing + * can reach. + * + * Waits for `isLoading` so an empty index mid-fetch never evicts a perfectly good id. + */ + useEffect(() => { + if (isLoading || !currentFolderId || folderById.has(currentFolderId)) return + void setFolderParams({ folderId: null }) + }, [isLoading, currentFolderId, folderById, setFolderParams]) + const breadcrumbs = useMemo(() => { if (!currentFolderId) return EMPTY_FOLDERS diff --git a/apps/sim/app/workspace/[workspaceId]/components/folders/use-folder-row-drag-drop.ts b/apps/sim/app/workspace/[workspaceId]/components/folders/use-folder-row-drag-drop.ts index 5fa05a555e9..38b4ab9e58c 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/folders/use-folder-row-drag-drop.ts +++ b/apps/sim/app/workspace/[workspaceId]/components/folders/use-folder-row-drag-drop.ts @@ -128,7 +128,21 @@ export function useFolderRowDragDrop({ }, onDragOver: (e: DragEvent, rowId) => { const sourceRowId = draggedRowIdRef.current - if (!sourceRowId || isInvalidDropTarget(rowId, sourceRowId)) return + if (sourceRowId) { + if (isInvalidDropTarget(rowId, sourceRowId)) return + } else if (!e.dataTransfer.types.includes(DRAG_ROW_MIME)) { + /** + * No local source and no payload of ours — an external or foreign drag. Returning + * without `preventDefault` leaves the browser's default handling in place, which is + * what stops a dropped OS file from navigating the tab away from the app. + */ + return + } + /** + * `dataTransfer.getData` is empty during dragover by design (the drag data store is + * protected until drop), so a drag that began in another mount of this page can only be + * recognised by its MIME type here. `onDrop` re-checks validity with the real payload. + */ e.preventDefault() e.stopPropagation() e.dataTransfer.dropEffect = 'move' diff --git a/apps/sim/app/workspace/[workspaceId]/files/files.tsx b/apps/sim/app/workspace/[workspaceId]/files/files.tsx index 7536b67bf21..da8ac589312 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/files.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/files.tsx @@ -62,6 +62,10 @@ import { timeCell, } from '@/app/workspace/[workspaceId]/components' import type { MoveOptionNode } from '@/app/workspace/[workspaceId]/components/folders' +import { + parseMoveOptionValue, + ROOT_MOVE_OPTION_VALUE, +} from '@/app/workspace/[workspaceId]/components/folders' import { FilesActionBar } from '@/app/workspace/[workspaceId]/files/components/action-bar' import { DeleteConfirmModal } from '@/app/workspace/[workspaceId]/files/components/delete-confirm-modal' import { FileRowContextMenu } from '@/app/workspace/[workspaceId]/files/components/file-row-context-menu' @@ -378,12 +382,34 @@ export function Files() { directSize.set(file.folderId, (directSize.get(file.folderId) ?? 0) + file.size) } } + /** + * Children indexed once rather than re-scanning `folders` per node — the roll-up visits + * every folder, so the filter made this quadratic. + */ + const childrenByParent = new Map() + for (const folder of folders) { + if (!folder.parentId) continue + const siblings = childrenByParent.get(folder.parentId) + if (siblings) siblings.push(folder) + else childrenByParent.set(folder.parentId, [folder]) + } + const totalSize = new Map() + /** + * `visiting` terminates a parent/child cycle. The optimistic folder-move write can produce + * one in cache, and without the guard this recurses until the stack blows and takes the + * whole page down — the same guard the shared folder helpers carry. + */ + const visiting = new Set() const getTotal = (folderId: string): number => { - if (totalSize.has(folderId)) return totalSize.get(folderId)! - const children = folders.filter((f) => f.parentId === folderId) + const cached = totalSize.get(folderId) + if (cached !== undefined) return cached + if (visiting.has(folderId)) return 0 + visiting.add(folderId) const size = - (directSize.get(folderId) ?? 0) + children.reduce((s, c) => s + getTotal(c.id), 0) + (directSize.get(folderId) ?? 0) + + (childrenByParent.get(folderId) ?? []).reduce((sum, child) => sum + getTotal(child.id), 0) + visiting.delete(folderId) totalSize.set(folderId, size) return size } @@ -1380,7 +1406,7 @@ export function Files() { const handleContextMenuMove = useCallback( async (optionValue: string) => { - const targetFolderId = optionValue === '__root__' ? null : optionValue + const targetFolderId = parseMoveOptionValue(optionValue) try { await moveItems.mutateAsync({ workspaceId, @@ -1767,7 +1793,7 @@ export function Files() { .sort((a, b) => a.sortOrder - b.sortOrder || a.name.localeCompare(b.name)) .map((f) => ({ value: f.id, label: f.name, children: buildSubtree(f.id) })) - return [{ value: '__root__', label: 'Files', children: [] }, ...buildSubtree(null)] + return [{ value: ROOT_MOVE_OPTION_VALUE, label: 'Files', children: [] }, ...buildSubtree(null)] }, [folders, selectedFolderIds, descendantFolderIdsByFolderId]) const sortConfig: SortConfig = useMemo( diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx index 4a990bb89a1..363d18363e0 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx @@ -61,7 +61,7 @@ import { useKnowledgeBasesList } from '@/hooks/kb/use-knowledge' import { useCreateFolder, useDeleteFolderMutation, useUpdateFolder } from '@/hooks/queries/folders' import { useDeleteKnowledgeBase, useUpdateKnowledgeBase } from '@/hooks/queries/kb/knowledge' import { usePinItem, usePinnedIds, useUnpinItem } from '@/hooks/queries/pinned-items' -import { useWorkspaceMembersQuery } from '@/hooks/queries/workspace' +import { useWorkspaceMembersQuery, type WorkspaceMember } from '@/hooks/queries/workspace' import { useDebounce } from '@/hooks/use-debounce' import { useDebouncedSearchSetter } from '@/hooks/use-debounced-search-setter' import { useInlineRename } from '@/hooks/use-inline-rename' @@ -166,6 +166,15 @@ export function Knowledge() { const { knowledgeBases, error } = useKnowledgeBasesList(workspaceId) const { data: members } = useWorkspaceMembersQuery(workspaceId) + /** + * Indexed once: `ownerCell` resolves a member per row, so passing the raw array makes the + * owner column O(rows x members) on every rebuild. Tables already does this. + */ + const membersById = useMemo(() => { + const byId = new Map() + for (const member of members ?? []) byId.set(member.userId, member) + return byId + }, [members]) /** * Two pin lookups: a folder pins under `resourceType: 'folder'`, which is a different pin * namespace from the knowledge bases it contains, so one set cannot answer for both. @@ -175,15 +184,23 @@ export function Knowledge() { const pinItem = usePinItem() const unpinItem = useUnpinItem() - if (error) { - logger.error('Failed to load knowledge bases:', error) - } + useEffect(() => { + if (error) logger.error('Failed to load knowledge bases:', error) + }, [error]) + const userPermissions = useUserPermissionsContext() const { mutateAsync: updateKnowledgeBaseMutation } = useUpdateKnowledgeBase(workspaceId) const { mutateAsync: deleteKnowledgeBaseMutation } = useDeleteKnowledgeBase(workspaceId) - const { currentFolderId, setCurrentFolderId, breadcrumbs, folders } = useFolderNavigation({ + const { + currentFolderId, + setCurrentFolderId, + breadcrumbs, + folders, + folderById, + isLoading: foldersLoading, + } = useFolderNavigation({ resourceType: FOLDER_RESOURCE_TYPE, workspaceId, }) @@ -411,9 +428,19 @@ export function Knowledge() { }, [folders, currentFolderId, debouncedSearchQuery, activeSort, pinnedFolderIds]) const processedKBs = useMemo(() => { - let result = filterKnowledgeBases(knowledgeBases, debouncedSearchQuery).filter( - (kb) => (kb.folderId ?? null) === currentFolderId - ) + /** + * A `folderId` that no longer names an active folder — a base restored on its own out of + * Recently Deleted while its folder stayed archived, or a cascade that failed partway — + * would otherwise match no level at all and leave the base unreachable from every view. + * Fall it back to the root instead. Skipped while the folder list is still loading, when + * an empty index would transiently drag every base to the root. + */ + let result = filterKnowledgeBases(knowledgeBases, debouncedSearchQuery).filter((kb) => { + const folderId = kb.folderId ?? null + const effectiveFolderId = + foldersLoading || !folderId || folderById.has(folderId) ? folderId : null + return effectiveFolderId === currentFolderId + }) if (connectorFilter.length > 0) { result = result.filter((kb) => { @@ -469,8 +496,8 @@ export function Knowledge() { cmp = (a.connectorTypes?.length ?? 0) - (b.connectorTypes?.length ?? 0) break case 'owner': - cmp = (members?.find((m) => m.userId === a.userId)?.name ?? '').localeCompare( - members?.find((m) => m.userId === b.userId)?.name ?? '' + cmp = (membersById.get(a.userId)?.name ?? '').localeCompare( + membersById.get(b.userId)?.name ?? '' ) break } @@ -479,12 +506,14 @@ export function Knowledge() { }, [ knowledgeBases, currentFolderId, + folderById, + foldersLoading, debouncedSearchQuery, connectorFilter, contentFilter, ownerFilter, activeSort, - members, + membersById, pinnedBaseIds, ]) @@ -497,7 +526,7 @@ export function Knowledge() { tokens: { label: EMPTY_CELL_PLACEHOLDER }, connectors: { label: EMPTY_CELL_PLACEHOLDER }, created: timeCell(folder.createdAt), - owner: ownerCell(folder.userId, members), + owner: ownerCell(folder.userId, membersById), updated: timeCell(folder.updatedAt), }, }) @@ -521,14 +550,14 @@ export function Knowledge() { }, connectors: connectorCell(kb.connectorTypes), created: timeCell(kb.createdAt), - owner: ownerCell(kb.userId, members), + owner: ownerCell(kb.userId, membersById), updated: timeCell(kb.updatedAt), }, } }) return [...folderRows, ...knowledgeBaseRows] - }, [visibleFolders, processedKBs, members, pinnedFolderIds, pinnedBaseIds]) + }, [visibleFolders, processedKBs, membersById, pinnedFolderIds, pinnedBaseIds]) /** * Rename is layered over the built rows rather than folded into the builder above, so a @@ -783,6 +812,7 @@ export function Knowledge() { await updateKnowledgeBaseMutation({ knowledgeBaseId, updates: { folderId } }) } catch (moveError) { logger.error('Failed to move knowledge base', moveError) + toast.error(getErrorMessage(moveError, 'Failed to move knowledge base')) } }, [updateKnowledgeBaseMutation] diff --git a/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx b/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx index 29bb86e2277..c757de3a59b 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx @@ -133,9 +133,14 @@ export function Tables() { workspaceId, }) - if (error) { - logger.error('Failed to load tables:', error) - } + /** + * Logged from an effect, not the render body: a render-phase log fires again on every + * re-render while the error persists, and on each of React's double renders in dev. + */ + useEffect(() => { + if (error) logger.error('Failed to load tables:', error) + }, [error]) + const deleteTable = useDeleteTable(workspaceId) const renameTable = useRenameTable(workspaceId) const createTable = useCreateTable(workspaceId) @@ -268,19 +273,25 @@ export function Tables() { const bPinned = pinnedFolderIds.has(b.id) if (aPinned !== bPinned) return aPinned ? -1 : 1 - // Folders carry none of the table-specific columns, so `columns`/`rows`/ - // `owner` fall back to name rather than producing an arbitrary order. + /** + * Read from `activeSort`, not the raw params: `tablesSortParams` is defaulted, so + * `sortColumn` is never null and folders would sort newest-first on a clean URL while + * Files and Knowledge sort them A→Z. Folders also carry none of the table-specific + * columns, so `columns`/`rows`/`owner` fall back to name rather than an arbitrary order. + */ + const col = activeSort?.column ?? 'name' + const dir = activeSort?.direction ?? 'asc' let cmp = 0 - if (sortColumn === 'created') { + if (col === 'created') { cmp = a.createdAt.getTime() - b.createdAt.getTime() - } else if (sortColumn === 'updated') { + } else if (col === 'updated') { cmp = a.updatedAt.getTime() - b.updatedAt.getTime() } else { cmp = a.name.localeCompare(b.name) } - return sortDirection === 'asc' ? cmp : -cmp + return dir === 'asc' ? cmp : -cmp }) - }, [folders, currentFolderId, debouncedSearchTerm, sortColumn, sortDirection, pinnedFolderIds]) + }, [folders, currentFolderId, debouncedSearchTerm, activeSort, pinnedFolderIds]) const processedTables = useMemo(() => { const query = debouncedSearchTerm.trim().toLowerCase() @@ -452,6 +463,18 @@ export function Tables() { disabled: !canEdit, onClick: () => breadcrumbRename.startRename(folder.id, folder.name), }, + { + label: 'Delete', + disabled: !canEdit, + /** + * The only way to delete the folder you are inside — its own row is not in the list. + * This is what makes the step-out in `handleDeleteFolder` reachable. + */ + onClick: () => { + setActiveFolder(folder) + setIsDeleteFolderDialogOpen(true) + }, + }, ] }, [currentFolderId, folderById, canEdit, breadcrumbRename.startRename]) 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 514954d74d0..0e6ac294fb2 100644 --- a/apps/sim/ee/workspace-forking/lib/copy/copy-workflows.ts +++ b/apps/sim/ee/workspace-forking/lib/copy/copy-workflows.ts @@ -45,8 +45,9 @@ interface ResolveForkFolderMappingParams { * Source folder ids that will directly hold copied content (workflows); null entries * (root-placed content) are ignored. A source folder is copied into the target only when * its subtree contains at least one of these, so a fork/sync never creates folders that - * would end up empty. Copied workspace FILES never influence this set: they live in the - * separate `workspace_file_folders` entity and are flattened to root by the copy. + * would end up empty. Copied workspace FILES never influence this set: their folders are a + * separate tree (`folder` rows with `resourceType = 'file'`, which this copy only ever reads + * as `'workflow'`) and are flattened to root by the copy. */ contentFolderIds: ReadonlyArray } diff --git a/apps/sim/lib/api/contracts/folders.ts b/apps/sim/lib/api/contracts/folders.ts index 3dfdacee2d1..d7ab87430d0 100644 --- a/apps/sim/lib/api/contracts/folders.ts +++ b/apps/sim/lib/api/contracts/folders.ts @@ -21,7 +21,7 @@ export type FolderResourceType = z.output * never sends the field, working against a new pod. A value that is present but not in the * enum is REJECTED with a 400, never silently coerced to `'workflow'`: coercing would file a * knowledge-base folder into the workflow tree, where the Knowledge page can never see it - * again. This is the deploy-ordering contract — see the note in the PR description. + * again. This is the deploy-ordering contract, pinned by `folders.test.ts`. */ export const servedFolderResourceTypeSchema = z .enum(['workflow', 'knowledge_base', 'table'], { diff --git a/apps/sim/lib/folders/config.ts b/apps/sim/lib/folders/config.ts index b99b02d92c4..542901b789d 100644 --- a/apps/sim/lib/folders/config.ts +++ b/apps/sim/lib/folders/config.ts @@ -256,37 +256,6 @@ async function selectChildIds( return rows.map((row) => String(row.id)) } -/** - * Restores the workflows this cascade archived, through the canonical workflow restore. - * - * A bare `workflow.archivedAt = null` is NOT the inverse of the archive: `archiveWorkflow` - * also archives the workflow's schedules (status `disabled`, `nextRunAt` cleared), webhooks - * (`isActive: false`), chats (`isActive: false`), and MCP tools. Restoring only the workflow - * row reports success while every schedule stays disabled forever and every chat 404s — - * silently, because the workflow itself looks fine. - * - * Deployment state is deliberately NOT revived: `isDeployed`/`isPublicApi` and - * `workflowDeploymentVersion.isActive` stay off, so a restore never re-exposes a public - * endpoint without an explicit redeploy. That matches the single-workflow restore path. - */ -async function restoreWorkflowChildren(context: CascadeChildrenContext): Promise { - const { restoreWorkflow } = await import('@/lib/workflows/lifecycle') - const ids = await selectChildIds(FOLDER_RESOURCES.workflow, context, 'archived') - - const restoringFolderIds = new Set(context.folderIds) - - let restored = 0 - for (const id of ids) { - const result = await restoreWorkflow(id, { - requestId: `folder-cascade-${context.folderIds[0]}`, - restoringFolderIds, - }) - if (result.restored) restored += 1 - } - - return restored -} - /** * Archives the knowledge bases in a folder subtree through the canonical KB delete, which * also archives their documents and pauses their connectors. A bare `knowledge_base` row @@ -415,6 +384,22 @@ export const FOLDER_RESOURCES: Record buildSoftDeleteSet: (timestamp, now) => ({ archivedAt: timestamp, updatedAt: now }) satisfies Partial, sortOrderColumn: workflow.sortOrder, + /** + * Restored in bulk rather than through a `restoreChildren` hook. `restoreFolderCascade` + * already matches these on the archive timestamp, so a webhook or chat the user archived + * independently stays archived — and it does so in a fixed number of statements inside the + * restore transaction. Routing them through `restoreWorkflow` instead would add a + * per-workflow read/transaction/read outside that transaction: ~1600 round trips for a + * folder of 200 workflows, and a window where the workflows are active but the folder is + * not. It would also buy nothing, since `restoreWorkflow` clears exactly these columns. + * + * What none of these can undo is the state `archiveWorkflow` overwrites — schedules go to + * `status: 'disabled'` with `nextRunAt` cleared, webhooks and chats to `isActive: false`. + * Archive does not record what those were, so restoring them to a constant would re-enable + * a schedule the user had disabled and re-run a completed one. Re-enabling stays explicit + * (redeploy re-activates a schedule), matching deployment state, which restore also leaves + * off on purpose. + */ restoreDependents: [ { table: workflowSchedule, @@ -451,7 +436,6 @@ export const FOLDER_RESOURCES: Record ], supportsLocking: true, archiveChildren: archiveWorkflowChildren, - restoreChildren: restoreWorkflowChildren, guardDelete: guardLastWorkflows, }, file: { diff --git a/apps/sim/lib/resources/orchestration/restore-resource.ts b/apps/sim/lib/resources/orchestration/restore-resource.ts index 81867fdfcd4..94bf8af0adb 100644 --- a/apps/sim/lib/resources/orchestration/restore-resource.ts +++ b/apps/sim/lib/resources/orchestration/restore-resource.ts @@ -35,6 +35,12 @@ export type RestorableResourceType = * Folder trees the generic engine restores, keyed by the restorable type that names them. * `'folder'` stays the workflow tree so the existing tool contract does not shift meaning; * the other trees get their own names, mirroring how `file_folder` already sits beside it. + * + * `knowledge_folder` and `table_folder` are accepted here and by the tool handler, but the + * model cannot emit them yet: the `restore_resource` parameter enum lives in the copilot + * service's `contracts/tool-catalog-v1.json`, which is a different repository and is mirrored + * into `lib/copilot/generated/**` by `scripts/sync-tool-catalog.ts`. Widening it there makes + * these reachable with no further change on this side. */ const FOLDER_RESOURCE_TYPE_BY_RESTORABLE: Partial< Record diff --git a/apps/sim/lib/table/service.ts b/apps/sim/lib/table/service.ts index 12ad8364f7d..decdd159909 100644 --- a/apps/sim/lib/table/service.ts +++ b/apps/sim/lib/table/service.ts @@ -634,6 +634,7 @@ export async function renameTable( */ export async function moveTableToFolder( tableId: string, + workspaceId: string, folderId: string | null, requestId: string, actingUserId?: string @@ -643,10 +644,22 @@ export async function moveTableToFolder( updatedAt: new Date(), } + /** + * Scoped on workspace and active state, not just id: this is exported from `@/lib/table`, + * so the caller's own authorization is not something the write can assume. An archived + * table must not be quietly reparented either — it would come back out of Recently Deleted + * somewhere the user never put it. + */ const result = await db .update(userTableDefinitions) .set(updates) - .where(eq(userTableDefinitions.id, tableId)) + .where( + and( + eq(userTableDefinitions.id, tableId), + eq(userTableDefinitions.workspaceId, workspaceId), + isNull(userTableDefinitions.archivedAt) + ) + ) .returning({ name: userTableDefinitions.name, createdBy: userTableDefinitions.createdBy, @@ -657,7 +670,7 @@ export async function moveTableToFolder( throw new Error(`Table ${tableId} not found`) } - const { name, createdBy, workspaceId } = result[0] + const { name, createdBy } = result[0] const actorId = actingUserId ?? createdBy if (actorId) { recordAudit({ diff --git a/apps/sim/lib/workflows/lifecycle.ts b/apps/sim/lib/workflows/lifecycle.ts index f7b63786284..965ae9ca62b 100644 --- a/apps/sim/lib/workflows/lifecycle.ts +++ b/apps/sim/lib/workflows/lifecycle.ts @@ -196,12 +196,6 @@ export async function archiveWorkflow( interface RestoreWorkflowOptions { requestId: string - /** - * Folder ids being restored alongside this workflow. A folder cascade restores its - * children BEFORE it un-archives the folder rows, so without this the re-root check below - * sees the folder still archived and dumps every workflow at the workspace root. - */ - restoringFolderIds?: ReadonlySet } export async function restoreWorkflow( @@ -227,7 +221,7 @@ export async function restoreWorkflow( } let clearFolderId = false - if (existingWorkflow.folderId && !options.restoringFolderIds?.has(existingWorkflow.folderId)) { + if (existingWorkflow.folderId) { const [folder] = await db .select({ archivedAt: folderTable.deletedAt }) .from(folderTable) @@ -242,6 +236,18 @@ export async function restoreWorkflow( const now = new Date() + /** + * `archiveWorkflow` stamps the workflow and its dependents with ONE timestamp, and only + * touches dependents that were still active. So the workflow's own `archivedAt` identifies + * exactly the rows this archive took down. + * + * Restoring by `workflowId` alone instead resurrects a webhook or chat the user had archived + * independently, days earlier — it was never part of this archive and the user never asked + * for it back. Matching the stamp is the same rule the folder cascade uses, and it is what + * the restore semantics documented for this feature actually promise. + */ + const archivedAt = existingWorkflow.archivedAt + await db.transaction(async (tx) => { await tx .update(workflow) @@ -255,22 +261,29 @@ export async function restoreWorkflow( await tx .update(workflowSchedule) .set({ archivedAt: null, updatedAt: now }) - .where(eq(workflowSchedule.workflowId, workflowId)) + .where( + and( + eq(workflowSchedule.workflowId, workflowId), + eq(workflowSchedule.archivedAt, archivedAt) + ) + ) await tx .update(webhook) .set({ archivedAt: null, updatedAt: now }) - .where(eq(webhook.workflowId, workflowId)) + .where(and(eq(webhook.workflowId, workflowId), eq(webhook.archivedAt, archivedAt))) await tx .update(chat) .set({ archivedAt: null, updatedAt: now }) - .where(eq(chat.workflowId, workflowId)) + .where(and(eq(chat.workflowId, workflowId), eq(chat.archivedAt, archivedAt))) await tx .update(workflowMcpTool) .set({ archivedAt: null, updatedAt: now }) - .where(eq(workflowMcpTool.workflowId, workflowId)) + .where( + and(eq(workflowMcpTool.workflowId, workflowId), eq(workflowMcpTool.archivedAt, archivedAt)) + ) }) logger.info(`[${options.requestId}] Restored workflow ${workflowId}`) diff --git a/packages/db/migrations/0274_file_folder_cutover_reconcile.sql b/packages/db/migrations/0274_file_folder_cutover_reconcile.sql index 3518898a416..0be032c63ac 100644 --- a/packages/db/migrations/0274_file_folder_cutover_reconcile.sql +++ b/packages/db/migrations/0274_file_folder_cutover_reconcile.sql @@ -20,44 +20,53 @@ -- -- IMPORTANT — run this AGAIN once the deploy has fully drained. Old pods keep writing the -- legacy table until they are gone, so anything they write during the rolling window lands --- after this runs. Both statements are idempotent and safe to repeat; a journaled migration --- does not replay, so the post-drain pass is an operational step, not an automatic one. +-- after this runs. The block is idempotent and safe to repeat; a journaled migration does not +-- replay, so the post-drain pass is an operational step, not an automatic one. +-- +-- On an environment where 0272 has not yet run — production at time of writing — 0272 and this +-- file apply back to back and this is a no-op reconcile over the rows 0272 just wrote. Verified +-- against production: 1,493 legacy file folders, 0 active name duplicates, 0 orphaned parents, +-- 0 cross-workspace parents, and 0 id collisions with `workflow_folder`, so the insert path +-- cannot trip the unique index, the FKs, or the resource-type trigger. --- If pass 1 commits and pass 2 fails, the mirrored rows are left holding parked names. That is --- invisible in production — the old code does not read `folder`'s file rows at all, and the new --- code is not serving yet, because migrations run ahead of the deploy — and re-running this --- migration repairs it. +-- Wrapped in a DO block so the two passes are ONE statement and therefore atomic on their own. +-- 0272 ends with an embedded COMMIT (its trailing CONCURRENTLY index builds cannot run inside a +-- transaction), so this file is not guaranteed to run inside drizzle's batch transaction. A +-- bare two-statement form could commit the parking pass and then fail the reconcile, leaving +-- every mirrored folder holding a placeholder name. +DO $$ +BEGIN + -- Pass 1: park every mirrored name out of the way. The partial unique index covers only + -- active rows, and `'__0274_tmp__' || id` is unique by construction, so after this no + -- pre-existing mirrored row can collide with the true state written below. Rows in `folder` + -- with no source row are left untouched by the join — at cutover time none can exist, since + -- nothing but 0272 has ever written `resource_type = 'file'`. + UPDATE "folder" f + SET "name" = '__0274_tmp__' || f."id" + FROM "workspace_file_folders" w + WHERE f."id" = w."id" + AND f."resource_type" = 'file' + AND f."deleted_at" IS NULL; --- Pass 1: park every mirrored name out of the way. The partial unique index covers only --- active rows, and `'__0274_tmp__' || id` is unique by construction, so after this no --- pre-existing mirrored row can collide with the true state written below. Rows in `folder` --- with no source row are left untouched by the join — at cutover time none can exist, since --- nothing but 0272 has ever written `resource_type = 'file'`. -UPDATE "folder" f -SET "name" = '__0274_tmp__' || f."id" -FROM "workspace_file_folders" w -WHERE f."id" = w."id" - AND f."resource_type" = 'file' - AND f."deleted_at" IS NULL; ---> statement-breakpoint --- Pass 2: insert what is missing and reconcile what diverged, in one statement so parents and --- children land together — the self-referencing FK is checked as an AFTER-ROW trigger at end --- of statement, and `folder_parent_resource_type_match` reads NULL for a parent inserted later --- in the same statement and so does not fire. --- --- The conflict target is `(id)` DELIBERATELY, not the bare form: ids are the mirror key and --- must reconcile, whereas a NAME collision here would mean `workspace_file_folders` itself --- violated its own active-unique index. That must fail loudly rather than silently discard a --- folder — the bare `ON CONFLICT DO NOTHING` would swallow it and strand every file inside. -INSERT INTO "folder" (id, resource_type, name, user_id, workspace_id, parent_id, locked, sort_order, created_at, updated_at, deleted_at) -SELECT - f.id, 'file', f.name, - f.user_id, f.workspace_id, f.parent_id, false, f.sort_order, - f.created_at, f.updated_at, f.deleted_at -FROM "workspace_file_folders" f -ON CONFLICT (id) DO UPDATE SET - "name" = EXCLUDED."name", - "parent_id" = EXCLUDED."parent_id", - "sort_order" = EXCLUDED."sort_order", - "updated_at" = EXCLUDED."updated_at", - "deleted_at" = EXCLUDED."deleted_at"; + -- Pass 2: insert what is missing and reconcile what diverged, in one statement so parents + -- and children land together — the self-referencing FK is checked as an AFTER-ROW trigger at + -- end of statement, and `folder_parent_resource_type_match` reads NULL for a parent inserted + -- later in the same statement and so does not fire. + -- + -- The conflict target is `(id)` DELIBERATELY, not the bare form: ids are the mirror key and + -- must reconcile, whereas a NAME collision here would mean `workspace_file_folders` itself + -- violated its own active-unique index. That must fail loudly rather than silently discard a + -- folder — the bare `ON CONFLICT DO NOTHING` would swallow it and strand every file inside. + INSERT INTO "folder" (id, resource_type, name, user_id, workspace_id, parent_id, locked, sort_order, created_at, updated_at, deleted_at) + SELECT + f.id, 'file', f.name, + f.user_id, f.workspace_id, f.parent_id, false, f.sort_order, + f.created_at, f.updated_at, f.deleted_at + FROM "workspace_file_folders" f + ON CONFLICT (id) DO UPDATE SET + "name" = EXCLUDED."name", + "parent_id" = EXCLUDED."parent_id", + "sort_order" = EXCLUDED."sort_order", + "updated_at" = EXCLUDED."updated_at", + "deleted_at" = EXCLUDED."deleted_at"; +END $$; From 4fecd39179785e00142bd842735a11b8fc6ff2af Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 28 Jul 2026 21:17:16 -0700 Subject: [PATCH 06/11] docs(folders): record the 0272 + 0274 dry-run results on the migration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A read-only dry run materialises the complete post-0272 `folder` table from both source tables and asserts every constraint it declares: no NULL names, no primary-key collisions between the two source tables, no unique-index violations, no resource-type or workspace trigger violations, and no parent/user/workspace FK violations. 0272's dedup renames only workflow folders, never file folders — the latter is what lets pass 2 write raw names. --- .../0274_file_folder_cutover_reconcile.sql | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/packages/db/migrations/0274_file_folder_cutover_reconcile.sql b/packages/db/migrations/0274_file_folder_cutover_reconcile.sql index 0be032c63ac..cb8ad70608a 100644 --- a/packages/db/migrations/0274_file_folder_cutover_reconcile.sql +++ b/packages/db/migrations/0274_file_folder_cutover_reconcile.sql @@ -24,10 +24,17 @@ -- replay, so the post-drain pass is an operational step, not an automatic one. -- -- On an environment where 0272 has not yet run — production at time of writing — 0272 and this --- file apply back to back and this is a no-op reconcile over the rows 0272 just wrote. Verified --- against production: 1,493 legacy file folders, 0 active name duplicates, 0 orphaned parents, --- 0 cross-workspace parents, and 0 id collisions with `workflow_folder`, so the insert path --- cannot trip the unique index, the FKs, or the resource-type trigger. +-- file apply back to back and this is a no-op reconcile over the rows 0272 just wrote. +-- +-- Dry-run against production (read-only), materialising the full post-0272 `folder` table from +-- both source tables and checking every constraint the table declares: 34,991 rows, and 0 of +-- each of — NULL names, NULL required columns, primary-key collisions between the two source +-- tables, `folder_workspace_resource_parent_name_active_unique` violations, resource-type or +-- workspace violations of `folder_parent_resource_type_match`, and parent/user/workspace FK +-- violations. Longest generated name 55 chars. 0272's dedup renames 47 workflow folders and 0 +-- file folders (the legacy table's own active-unique index guarantees the latter, which is why +-- pass 2 below can write raw names). All 5,475 foldered files and 4,593 foldered workflows keep +-- a resolvable folder id. -- Wrapped in a DO block so the two passes are ONE statement and therefore atomic on their own. -- 0272 ends with an embedded COMMIT (its trailing CONCURRENTLY index builds cannot run inside a From 088801b8f141d6e1684ba45e0b5b6acb5518c803 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 28 Jul 2026 21:44:22 -0700 Subject: [PATCH 07/11] fix(folders): close the findings from the line-by-line audit of the PR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Data integrity - Workspace fork copied `folderId` verbatim onto the child's tables and knowledge bases. The column carries no workspace, so a forked row pointed at a folder owned by the SOURCE workspace — invisible in the fork, and mutated from under it when the source later deleted that folder (`ON DELETE SET NULL`). Latent until now because nothing wrote a non-null `folderId` for those two resources; this PR is what makes it reachable. Forked resources land at the root, like forked files already did Interaction bugs - Clearing a dead `?folderId=` wrote through the params' default `history: 'push'`, so Back returned to the dead URL, which healed and pushed again — the Back button was dead on that page. It replaces now: opening a folder is navigation, correcting a URL that never pointed anywhere is not - That same heal keyed off `isLoading`, which is false for a DISABLED query, false for an ERRORED one, and false while `keepPreviousData` is showing the previous workspace's folders. In all three the list is empty or stale, so a transient fetch failure — or a workspace switch — threw away a perfectly good open folder. Gated on `isSuccess && !isPlaceholderData` - "New folder" while a search was active created the folder but never showed it: the search filters folders too, so the new row did not match, the rename field never appeared, and the create read as a no-op. Both pages clear the search so the thing just created is on screen - The drag ghost was removed only by `dragend`, which fires on the source row. The table is virtualized, so scrolling the source out of view mid-drag unmounted it, the event never arrived, and the ghost stayed on the page with every row stuck at drag opacity. Cleaned up on unmount as the backstop - A drag begun in another mount highlighted every folder as a valid target — including the dragged folder itself — then silently did nothing on drop. It only highlights when the source is known and was actually checked Correctness - The folder-duplicate route caught 23505 across the WHOLE handler, so a unique violation raised while copying the workflows inside the folder was relabelled "a folder with this name already exists". Scoped to the folder INSERT, which is the only place the two conflicts the caller can act on (client-supplied `newId` replay, concurrent name take) actually arise - The optimistic folder create derived its sort order from the workspace's WORKFLOWS regardless of resource type. Only the workflow tree interleaves folders and resources in one ordering space, so a knowledge-base or table folder was placed against an unrelated one - A table archived between `checkAccess` and the move surfaced as a 500 with a "not found" body; it is a 404 - `FOLDER_RESOURCE_TYPE_BY_RESTORABLE` was a `Partial` record, so adding a folder tree without a mapping would compile and silently restore into the workflow tree. Total record now - Recently Deleted paired query results to folder trees by ARRAY POSITION; reordering the const would have filed knowledge folders under Tables with no type error. Keyed by type - The knowledge move's no-op guard compared against the snapshot taken when the menu opened rather than the live row Performance — the module split now actually does what it claimed - `knowledge-keys.ts` justified itself as keeping server prefetches off the ~1000-line `kb/knowledge` module, but `knowledge/prefetch.ts` still imported its stale-time constant from exactly that module, whose first line pulls the `@sim/emcn` barrel. Worse, this PR had ADDED the same class of edge to four prefetches via `FOLDER_LIST_STALE_TIME`/`mapFolder` - `FOLDER_LIST_STALE_TIME`, `mapFolder`, and `KNOWLEDGE_BASE_LIST_STALE_TIME` now live beside their key factories, so all four server prefetches import only keys-and-constants modules. `mapFolder` keeps its explicit field list rather than a spread, so the cached shape cannot drift from a client fetch Honesty in comments - The KB retention `deleteFilter` narrows the restore race but does not close it — documents hard-deleted by `onBatch` do not come back, so a restore landing mid-batch returns an emptied base. Said so - A failed folder restore leaves children active while their folders are still archived. They are reachable (both lists fall an unresolvable `folderId` back to the root) but they sit at the root until the restore is retried. Said so Product policy made visible - Restore deliberately does not re-enable schedules, webhooks, or chats, because archive overwrites their state without recording it. A deliberately-disabled schedule or webhook is a common state rather than an edge case, so reactivating to a constant would be wrong more often than right. Recently Deleted now says so at the moment of restore instead of leaving it to be discovered Smaller - Pin glyph: dropped the doubled gap, added `role='img'` so the state is announced - "Move here" carries the folder glyph its sibling leaves do - `useMoveTable` had stolen `useDeleteTable`'s doc block - Dead `.returning` column and a `?? null` on a non-nullable param in `moveTableToFolder` - Tables reads one sort source for both blocks, matching Knowledge - New test pins that `updateFolder` refuses an archived folder — the guard that stops a delete from unlocking its locked subfolders --- .../app/api/folders/[id]/duplicate/route.ts | 58 ++++++++++--------- apps/sim/app/api/table/[tableId]/route.ts | 23 +++++--- .../components/folders/folder-breadcrumbs.ts | 6 +- .../components/folders/move-options.tsx | 10 +++- .../folders/use-folder-navigation.ts | 29 ++++++++-- .../folders/use-folder-row-drag-drop.ts | 23 +++++++- .../components/resource/resource.tsx | 9 ++- .../workspace/[workspaceId]/home/prefetch.ts | 3 +- .../[workspaceId]/knowledge/knowledge.tsx | 12 +++- .../[workspaceId]/knowledge/prefetch.ts | 6 +- .../app/workspace/[workspaceId]/prefetch.ts | 3 +- .../recently-deleted/recently-deleted.tsx | 36 ++++++++++-- .../[workspaceId]/tables/prefetch.ts | 3 +- .../workspace/[workspaceId]/tables/tables.tsx | 12 +++- apps/sim/background/cleanup-soft-deletes.ts | 10 +++- .../lib/copy/copy-resources.ts | 10 ++++ apps/sim/hooks/queries/folders.ts | 50 +++++++--------- apps/sim/hooks/queries/kb/knowledge.ts | 7 ++- apps/sim/hooks/queries/tables.ts | 6 +- apps/sim/hooks/queries/utils/folder-keys.ts | 30 +++++++++- .../sim/hooks/queries/utils/knowledge-keys.ts | 3 + apps/sim/lib/folders/lifecycle.test.ts | 14 +++++ apps/sim/lib/folders/lifecycle.ts | 5 ++ .../orchestration/restore-resource.ts | 12 +++- apps/sim/lib/table/service.ts | 3 +- 25 files changed, 279 insertions(+), 104 deletions(-) diff --git a/apps/sim/app/api/folders/[id]/duplicate/route.ts b/apps/sim/app/api/folders/[id]/duplicate/route.ts index aaaabfdc31e..9cd730013ae 100644 --- a/apps/sim/app/api/folders/[id]/duplicate/route.ts +++ b/apps/sim/app/api/folders/[id]/duplicate/route.ts @@ -123,18 +123,38 @@ export const POST = withRouteHandler( FOLDER_RESOURCE_TYPE ) - await tx.insert(folderTable).values({ - id: newFolderId, - resourceType: FOLDER_RESOURCE_TYPE, - userId: session.user.id, - workspaceId: targetWorkspaceId, - name: deduplicatedName, - parentId: targetParentId, - sortOrder, - locked: false, - createdAt: now, - updatedAt: now, - }) + try { + await tx.insert(folderTable).values({ + id: newFolderId, + resourceType: FOLDER_RESOURCE_TYPE, + userId: session.user.id, + workspaceId: targetWorkspaceId, + name: deduplicatedName, + parentId: targetParentId, + sortOrder, + locked: false, + createdAt: now, + updatedAt: now, + }) + } catch (insertError) { + /** + * Scoped to THIS insert on purpose. A 23505 here is one of two real conflicts the + * caller can act on: `newId` is client-supplied, so replaying a duplicate whose + * response was lost hits the primary key; and `deduplicateFolderName` runs before + * the write, so a concurrent create can still take the name in between. + * + * Catching 23505 across the whole handler instead would relabel any unique violation + * raised while copying the workflows inside the folder — a different constraint, on a + * different table — as a folder-name conflict, which is both wrong and misleading. + * Those keep falling through to the generic 500. + */ + if (getPostgresErrorCode(insertError) !== '23505') throw insertError + throw new FolderDuplicationError( + `Folder duplication conflicted for ${sourceFolderId}`, + 409, + 'A folder with this name already exists in this location' + ) + } const folderMapping = new Map([[sourceFolderId, newFolderId]]) await duplicateFolderStructure( @@ -201,20 +221,6 @@ export const POST = withRouteHandler( return NextResponse.json({ error: error.message }, { status: error.status }) } - /** - * `newId` is client-supplied, so replaying a duplicate whose response was lost hits the - * primary key rather than the name index. A name collision is equally reachable: dedup - * runs before the INSERT, but a concurrent create can take the name in between. Either - * way this is a conflict the caller can act on, not a server fault. - */ - if (getPostgresErrorCode(error) === '23505') { - logger.warn(`[${requestId}] Folder duplication conflicted`, { sourceFolderId }) - return NextResponse.json( - { error: 'A folder with this name already exists in this location' }, - { status: 409 } - ) - } - if (error instanceof FolderDuplicationError) { logger.warn(`[${requestId}] Folder duplication rejected: ${error.message}`, { sourceFolderId, diff --git a/apps/sim/app/api/table/[tableId]/route.ts b/apps/sim/app/api/table/[tableId]/route.ts index b6b91bde24e..03eaa4c7243 100644 --- a/apps/sim/app/api/table/[tableId]/route.ts +++ b/apps/sim/app/api/table/[tableId]/route.ts @@ -196,13 +196,22 @@ export const PATCH = withRouteHandler( ) { return NextResponse.json({ error: 'Folder not found in this workspace' }, { status: 404 }) } - await moveTableToFolder( - tableId, - table.workspaceId, - validated.folderId, - requestId, - authResult.userId - ) + try { + await moveTableToFolder( + tableId, + table.workspaceId, + validated.folderId, + requestId, + authResult.userId + ) + } catch (moveError) { + // The move re-asserts workspace and active state, so a miss means the table was + // archived between `checkAccess` and the write. That is a 404, not a server fault. + if (moveError instanceof Error && moveError.message.endsWith('not found')) { + return NextResponse.json({ error: 'Table not found' }, { status: 404 }) + } + throw moveError + } } // Re-read so the response reflects both a rename and a lock change. diff --git a/apps/sim/app/workspace/[workspaceId]/components/folders/folder-breadcrumbs.ts b/apps/sim/app/workspace/[workspaceId]/components/folders/folder-breadcrumbs.ts index 4efa3fd8fbc..67e333a5c9b 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/folders/folder-breadcrumbs.ts +++ b/apps/sim/app/workspace/[workspaceId]/components/folders/folder-breadcrumbs.ts @@ -1,3 +1,4 @@ +import type { ElementType } from 'react' import type { BreadcrumbEditing, BreadcrumbItem, @@ -8,7 +9,7 @@ import type { WorkflowFolder } from '@/stores/folders/types' export interface FolderBreadcrumbItemsOptions { /** Root crumb label — the page's own name ("Knowledge Base", "Tables"). */ rootLabel: string - rootIcon?: React.ElementType + rootIcon?: ElementType /** Root-first ancestor chain of the open folder, from `useFolderNavigation`. */ breadcrumbs: WorkflowFolder[] /** Called with the folder to open, or `null` for the workspace root. */ @@ -46,10 +47,9 @@ export function folderBreadcrumbItems({ breadcrumbs.forEach((folder, index) => { const isCurrent = index === breadcrumbs.length - 1 + /** The open folder is where you already are, so its crumb is not a navigation target. */ items.push({ label: folder.name, - // The current folder is where you already are, so its crumb is not a navigation - // target — it carries the folder's own actions instead. onClick: isCurrent ? undefined : () => onNavigate(folder.id), dropdownItems: isCurrent && currentFolderActions?.length ? currentFolderActions : undefined, editing: isCurrent ? currentFolderEditing : undefined, diff --git a/apps/sim/app/workspace/[workspaceId]/components/folders/move-options.tsx b/apps/sim/app/workspace/[workspaceId]/components/folders/move-options.tsx index ec75e82034a..66f0b2e455a 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/folders/move-options.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/folders/move-options.tsx @@ -42,6 +42,11 @@ export interface BuildMoveOptionsParams { * * Children are indexed by parent once so the tree walk stays linear in the number of * folders rather than re-filtering the whole list at every level. + * + * `buildSubtree` needs no cycle guard, and that is a property of the walk rather than an + * oversight: it descends from the `null` root, and every folder inside a parent cycle has a + * parent inside that cycle, so no cycle member is ever reachable from the root. Such folders + * are omitted from the menu, which is the right answer — they are not valid destinations. */ export function buildMoveOptions({ folders, @@ -131,7 +136,10 @@ export function renderMoveOption( {option.label} - onMove(option.value)}>Move here + onMove(option.value)}> + + Move here + {option.children.map((child) => renderMoveOption(child, onMove))} diff --git a/apps/sim/app/workspace/[workspaceId]/components/folders/use-folder-navigation.ts b/apps/sim/app/workspace/[workspaceId]/components/folders/use-folder-navigation.ts index d6005250adb..8a94b0fc093 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/folders/use-folder-navigation.ts +++ b/apps/sim/app/workspace/[workspaceId]/components/folders/use-folder-navigation.ts @@ -52,7 +52,22 @@ export function useFolderNavigation({ folderNavUrlKeys ) - const { data: folders = EMPTY_FOLDERS, isLoading } = useFolders(workspaceId, { resourceType }) + const { + data: folders = EMPTY_FOLDERS, + isLoading, + isSuccess, + isPlaceholderData, + } = useFolders(workspaceId, { resourceType }) + + /** + * The folder list is only trustworthy enough to evict a `folderId` when the query has + * actually succeeded for THIS workspace. `isLoading` alone is not that signal: it is false + * for a disabled query (no `workspaceId`), false for an errored one, and — because + * `useFolders` sets `keepPreviousData` — false while showing the previous workspace's + * folders during a workspace switch. In all three the list is empty or stale, and healing + * off it would throw away a perfectly good folder. + */ + const foldersAreAuthoritative = isSuccess && !isPlaceholderData const setCurrentFolderId = useCallback( (folderId: string | null) => { @@ -80,9 +95,15 @@ export function useFolderNavigation({ * Waits for `isLoading` so an empty index mid-fetch never evicts a perfectly good id. */ useEffect(() => { - if (isLoading || !currentFolderId || folderById.has(currentFolderId)) return - void setFolderParams({ folderId: null }) - }, [isLoading, currentFolderId, folderById, setFolderParams]) + if (!foldersAreAuthoritative || !currentFolderId || folderById.has(currentFolderId)) return + /** + * `history: 'replace'`, overriding the `push` these params default to. Opening a folder is + * a navigation and belongs in the back stack; correcting a URL that never pointed anywhere + * is not. Pushing here strands the user: Back returns to the dead `?folderId=`, which heals + * and pushes again, so Back never escapes the page. + */ + void setFolderParams({ folderId: null }, { history: 'replace' }) + }, [foldersAreAuthoritative, currentFolderId, folderById, setFolderParams]) const breadcrumbs = useMemo(() => { if (!currentFolderId) return EMPTY_FOLDERS diff --git a/apps/sim/app/workspace/[workspaceId]/components/folders/use-folder-row-drag-drop.ts b/apps/sim/app/workspace/[workspaceId]/components/folders/use-folder-row-drag-drop.ts index 38b4ab9e58c..b6a746b2606 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/folders/use-folder-row-drag-drop.ts +++ b/apps/sim/app/workspace/[workspaceId]/components/folders/use-folder-row-drag-drop.ts @@ -1,6 +1,6 @@ 'use client' -import { type DragEvent, useCallback, useMemo, useRef, useState } from 'react' +import { type DragEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { parseFolderedRowId } from '@/app/workspace/[workspaceId]/components/folders/folder-row-id' import type { RowDragDropConfig } from '@/app/workspace/[workspaceId]/components/resource/resource' @@ -81,6 +81,20 @@ export function useFolderRowDragDrop({ onMoveResource, } + /** + * The ghost lives on `document.body`, but the only thing that removes it is `dragend`, which + * fires on the SOURCE ROW. `Resource.Table` is virtualized, so scrolling the source out of + * view mid-drag unmounts that row and the event never arrives — leaving the ghost stuck on + * the page and every row frozen at drag opacity. Clean up on unmount as the backstop. + */ + useEffect( + () => () => { + dragGhostRef.current?.remove() + dragGhostRef.current = null + }, + [] + ) + const isInvalidDropTarget = useCallback((targetRowId: string, sourceRowId: string) => { const target = parseFolderedRowId(targetRowId) if (target.kind !== 'folder') return true @@ -146,7 +160,12 @@ export function useFolderRowDragDrop({ e.preventDefault() e.stopPropagation() e.dataTransfer.dropEffect = 'move' - setActiveDropTargetId(rowId) + /** + * Highlight only when the source is known and was checked. Without it every folder + * would light up as a valid target — including the dragged folder itself and its own + * descendants — and the drop would then silently do nothing. + */ + if (sourceRowId) setActiveDropTargetId(rowId) }, onDragLeave: (e: DragEvent, rowId) => { const relatedTarget = e.relatedTarget diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx index 2166584a962..707cfdaeb9f 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx @@ -64,6 +64,9 @@ export interface ResourceCell { * rows sort to the top of every list, and without an indicator that ordering reads as * arbitrary. Pinning itself is an action on the row's context menu, deliberately not a * hover button here. + * + * Honoured only on the plain label cell: a cell rendering custom `content` owns its own + * layout, and the rename field replaces the label entirely while it is open. */ pinned?: boolean } @@ -526,7 +529,11 @@ const CellContent = memo(function CellContent({ {icon && {icon}} {pinned && ( - + )} ) diff --git a/apps/sim/app/workspace/[workspaceId]/home/prefetch.ts b/apps/sim/app/workspace/[workspaceId]/home/prefetch.ts index 508eca5002e..f08791c0bbd 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/prefetch.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/prefetch.ts @@ -2,8 +2,7 @@ import type { QueryClient } from '@tanstack/react-query' import type { FolderApi } from '@/lib/api/contracts' import type { ListWorkspaceFilesResponse } from '@/lib/api/contracts/workspace-files' import { prefetchInternalJson } from '@/app/workspace/[workspaceId]/lib/prefetch-internal-fetch' -import { FOLDER_LIST_STALE_TIME, mapFolder } from '@/hooks/queries/folders' -import { folderKeys } from '@/hooks/queries/utils/folder-keys' +import { FOLDER_LIST_STALE_TIME, folderKeys, mapFolder } from '@/hooks/queries/utils/folder-keys' import { WORKSPACE_FILES_LIST_STALE_TIME, workspaceFilesKeys, diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx index 363d18363e0..5cd831e02e1 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx @@ -691,6 +691,13 @@ export function Knowledge() { name, parentId: parentId ?? undefined, }) + /** + * A live search term filters the folder list too, so a brand-new "New folder" would not + * match it — the row never renders, the rename field never appears, and the create reads + * as a no-op even though it succeeded. Clear the search so the thing just created is on + * screen to be named. + */ + setSearchQuery('') // Drop straight into rename: the auto-generated name is a placeholder, and the user // should not have to hunt for a second action to replace it. listRenameRef.current.startRename(folderRowId(folder.id), folder.name) @@ -834,7 +841,10 @@ export function Knowledge() { const kb = activeKnowledgeBaseRef.current if (!kb) return const folderId = parseMoveOptionValue(optionValue) - if ((kb.folderId ?? null) !== folderId) await moveKnowledgeBaseTo(kb.id, folderId) + // Re-read placement from the live list: `activeKnowledgeBase` is a snapshot from when + // the menu opened, and a refetch since then would make the no-op check wrong. + const current = knowledgeBasesRef.current.find((item) => item.id === kb.id) ?? kb + if ((current.folderId ?? null) !== folderId) await moveKnowledgeBaseTo(kb.id, folderId) closeRowContextMenu() }, [moveKnowledgeBaseTo, closeRowContextMenu] diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/prefetch.ts b/apps/sim/app/workspace/[workspaceId]/knowledge/prefetch.ts index 1f5d60f3d94..996786083fd 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/prefetch.ts +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/prefetch.ts @@ -2,10 +2,8 @@ import type { QueryClient } from '@tanstack/react-query' import type { FolderApi } from '@/lib/api/contracts/folders' import type { KnowledgeBaseData } from '@/lib/api/contracts/knowledge' import { prefetchInternalJson } from '@/app/workspace/[workspaceId]/lib/prefetch-internal-fetch' -import { FOLDER_LIST_STALE_TIME, mapFolder } from '@/hooks/queries/folders' -import { KNOWLEDGE_BASE_LIST_STALE_TIME } from '@/hooks/queries/kb/knowledge' -import { folderKeys } from '@/hooks/queries/utils/folder-keys' -import { knowledgeKeys } from '@/hooks/queries/utils/knowledge-keys' +import { FOLDER_LIST_STALE_TIME, folderKeys, mapFolder } from '@/hooks/queries/utils/folder-keys' +import { KNOWLEDGE_BASE_LIST_STALE_TIME, knowledgeKeys } from '@/hooks/queries/utils/knowledge-keys' /** * Prefetches the workspace's knowledge-bases list AND its knowledge-base folder tree under diff --git a/apps/sim/app/workspace/[workspaceId]/prefetch.ts b/apps/sim/app/workspace/[workspaceId]/prefetch.ts index 425e53831ee..809cae87686 100644 --- a/apps/sim/app/workspace/[workspaceId]/prefetch.ts +++ b/apps/sim/app/workspace/[workspaceId]/prefetch.ts @@ -6,13 +6,12 @@ import { listWorkflowsForUser } from '@/lib/workflows/queries' import { getWorkspaceHostContextForViewer } from '@/lib/workspaces/host-context' import { listWorkspacesForViewer } from '@/lib/workspaces/list' import { getWorkspacePermissionsForAuthorizedViewer } from '@/lib/workspaces/permissions/utils' -import { FOLDER_LIST_STALE_TIME, mapFolder } from '@/hooks/queries/folders' import { MOTHERSHIP_CHAT_LIST_STALE_TIME, mapChat, mothershipChatKeys, } from '@/hooks/queries/mothership-chats' -import { folderKeys } from '@/hooks/queries/utils/folder-keys' +import { FOLDER_LIST_STALE_TIME, folderKeys, mapFolder } from '@/hooks/queries/utils/folder-keys' import { workflowKeys } from '@/hooks/queries/utils/workflow-keys' import { mapWorkflow, WORKFLOW_LIST_STALE_TIME } from '@/hooks/queries/utils/workflow-list-query' import { 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 b62d3a912e8..2cfaae3383f 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 @@ -162,6 +162,19 @@ const FOLDER_TREE_TAB_BY_TYPE = new Map( FOLDERED_RESOURCE_TREES.map((tree) => [tree.type, tree.tab]) ) +/** + * Restore brings a workflow back but deliberately does NOT re-enable its schedules, webhooks, + * or chats. Archive overwrites their enabled state without recording what it was, so restoring + * to a constant would re-enable something the user had switched off — on production roughly + * half of live schedules sit at `disabled` and ~12% of live webhooks at `isActive: false`, so + * that is the common case, not the edge case. + * + * Leaving it off is the safe choice; leaving it UNSAID is not. Restoring anything that carries + * automations says so at the moment of restore, so re-enabling is a known step rather than a + * silent surprise. + */ +const PAUSED_AUTOMATION_TYPES = new Set(['workflow', 'folder']) + function matchesActiveTab(resource: DeletedResource, activeTab: ResourceType): boolean { if (activeTab === 'all') return true if (activeTab === 'file') return resource.type === 'file' || resource.type === 'workspace_folder' @@ -288,9 +301,20 @@ export function RecentlyDeleted() { }) } - const folderTreeData = [knowledgeFoldersQuery.data, tableFoldersQuery.data] - FOLDERED_RESOURCE_TREES.forEach((tree, index) => { - for (const folder of folderTreeData[index] ?? []) { + /** + * Keyed by `tree.type`, not by array position: an index-aligned pairing would silently file + * knowledge folders under Tables (and vice versa) if the const above were ever reordered, + * with no type error and no test to catch it. + */ + const folderTreeData: Record< + (typeof FOLDERED_RESOURCE_TREES)[number]['type'], + typeof knowledgeFoldersQuery.data + > = { + knowledge_folder: knowledgeFoldersQuery.data, + table_folder: tableFoldersQuery.data, + } + FOLDERED_RESOURCE_TREES.forEach((tree) => { + for (const folder of folderTreeData[tree.type] ?? []) { items.push({ id: folder.id, name: folder.name, @@ -538,7 +562,11 @@ export function RecentlyDeleted() { ) : isRestored ? (
- Restored + + {PAUSED_AUTOMATION_TYPES.has(resource.type) + ? 'Restored \u00b7 schedules and webhooks stay paused' + : 'Restored'} + handleView(resource)}> View diff --git a/apps/sim/app/workspace/[workspaceId]/tables/prefetch.ts b/apps/sim/app/workspace/[workspaceId]/tables/prefetch.ts index 6eacb992359..196aa207bba 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/prefetch.ts +++ b/apps/sim/app/workspace/[workspaceId]/tables/prefetch.ts @@ -2,8 +2,7 @@ import type { QueryClient } from '@tanstack/react-query' import type { FolderApi } from '@/lib/api/contracts/folders' import type { TableDefinition } from '@/lib/table' import { prefetchInternalJson } from '@/app/workspace/[workspaceId]/lib/prefetch-internal-fetch' -import { FOLDER_LIST_STALE_TIME, mapFolder } from '@/hooks/queries/folders' -import { folderKeys } from '@/hooks/queries/utils/folder-keys' +import { FOLDER_LIST_STALE_TIME, folderKeys, mapFolder } from '@/hooks/queries/utils/folder-keys' import { TABLE_LIST_STALE_TIME, tableKeys } from '@/hooks/queries/utils/table-keys' /** diff --git a/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx b/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx index c757de3a59b..5919b61707e 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx @@ -294,6 +294,9 @@ export function Tables() { }, [folders, currentFolderId, debouncedSearchTerm, activeSort, pinnedFolderIds]) const processedTables = useMemo(() => { + // Same source as `visibleFolders` above, so the two blocks can never disagree on order. + const sortColumn = activeSort?.column ?? 'updated' + const sortDirection = activeSort?.direction ?? 'desc' const query = debouncedSearchTerm.trim().toLowerCase() /** * A `folderId` that no longer names an active folder — restored on its own out @@ -997,12 +1000,19 @@ export function Tables() { name: nextUntitledFolderName(folders, currentFolderId), parentId: currentFolderId ?? undefined, }) + /** + * A live search term filters the folder list too, so a brand-new "New folder" would not + * match it — the row never renders, the rename field never appears, and the create reads + * as a no-op even though it succeeded. Clear the search so the thing just created is on + * screen to be named. + */ + setSearchTerm('') startFolderRename(folder) } catch (err) { logger.error('Failed to create folder:', err) toast.error(getErrorMessage(err, 'Failed to create folder'), { duration: 5000 }) } - }, [workspaceId, folders, currentFolderId, createFolderAsync, startFolderRename]) + }, [workspaceId, folders, currentFolderId, createFolderAsync, setSearchTerm, startFolderRename]) const headerActions: ResourceAction[] = useMemo( () => [ diff --git a/apps/sim/background/cleanup-soft-deletes.ts b/apps/sim/background/cleanup-soft-deletes.ts index cb5e8a063ea..ccef446e784 100644 --- a/apps/sim/background/cleanup-soft-deletes.ts +++ b/apps/sim/background/cleanup-soft-deletes.ts @@ -398,9 +398,13 @@ async function cleanupExpiredKnowledgeBases( ) .limit(limit), /** - * Re-asserted on the DELETE because `onBatch` hard-deletes documents first, which leaves - * a real window in which a restore can land. Without it, a knowledge base restored in - * that window is hard-deleted anyway. + * Re-asserted on the DELETE because `onBatch` hard-deletes documents first, which leaves a + * real window in which a restore can land. + * + * This narrows the window rather than closing it: the base row survives, but documents + * already hard-deleted by `onBatch` do not come back, so a restore landing mid-batch + * returns an emptied knowledge base. Closing it properly means holding a row lock across + * select → onBatch → delete. */ deleteFilter: and( isNotNull(knowledgeBase.deletedAt), diff --git a/apps/sim/ee/workspace-forking/lib/copy/copy-resources.ts b/apps/sim/ee/workspace-forking/lib/copy/copy-resources.ts index 2b4aea14ae9..565d92bc9d8 100644 --- a/apps/sim/ee/workspace-forking/lib/copy/copy-resources.ts +++ b/apps/sim/ee/workspace-forking/lib/copy/copy-resources.ts @@ -517,6 +517,14 @@ export async function copyForkResourceContainers( ...definition, id: childTableId, workspaceId: childWorkspaceId, + /** + * Folders never transit a fork edge. `folder_id` is a global id with no workspace in + * it, so the spread above would leave the child's table pointing at a folder owned by + * the SOURCE workspace — invisible in the fork, and mutated from under it if the + * source later deletes that folder (`ON DELETE SET NULL`). Forked tables land at the + * root, like forked files already do. + */ + folderId: null, schema: remappedSchema, createdBy: userId, rowsVersion: 0, @@ -561,6 +569,8 @@ export async function copyForkResourceContainers( ...base, id: childKbId, workspaceId: childWorkspaceId, + /** Same reasoning as the table copy above: folders do not transit a fork edge. */ + folderId: null, userId, deletedAt: null, createdAt: now, diff --git a/apps/sim/hooks/queries/folders.ts b/apps/sim/hooks/queries/folders.ts index 0f02b4c6c66..8afbef3ed52 100644 --- a/apps/sim/hooks/queries/folders.ts +++ b/apps/sim/hooks/queries/folders.ts @@ -6,7 +6,6 @@ import { createFolderContract, deleteFolderContract, duplicateFolderContract, - type FolderApi, listFoldersContract, reorderFoldersContract, restoreFolderContract, @@ -14,7 +13,12 @@ import { updateFolderContract, } from '@/lib/api/contracts' import { getFolderMap } from '@/hooks/queries/utils/folder-cache' -import { type FolderQueryScope, folderKeys } from '@/hooks/queries/utils/folder-keys' +import { + FOLDER_LIST_STALE_TIME, + type FolderQueryScope, + folderKeys, + mapFolder, +} from '@/hooks/queries/utils/folder-keys' import { invalidateWorkflowLists } from '@/hooks/queries/utils/invalidate-workflow-lists' import { knowledgeKeys } from '@/hooks/queries/utils/knowledge-keys' import { @@ -28,30 +32,6 @@ import type { WorkflowFolder } from '@/stores/folders/types' const logger = createLogger('FolderQueries') -export const FOLDER_LIST_STALE_TIME = 60 * 1000 - -/** - * Maps a wire folder row to the client `WorkflowFolder` 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 { - return { - id: folder.id, - name: folder.name, - userId: folder.userId, - workspaceId: folder.workspaceId, - parentId: folder.parentId, - resourceType: folder.resourceType, - locked: folder.locked, - sortOrder: folder.sortOrder, - createdAt: new Date(folder.createdAt), - updatedAt: new Date(folder.updatedAt), - deletedAt: folder.deletedAt ? new Date(folder.deletedAt) : null, - } -} - async function fetchFolders( workspaceId: string, scope: FolderQueryScope = 'active', @@ -219,9 +199,19 @@ export function useCreateFolder() { queryClient, 'CreateFolder', (variables, tempId, previousFolders) => { - const currentWorkflows = Object.fromEntries( - getWorkflows(variables.workspaceId).map((w) => [w.id, w]) - ) + const resourceType = variables.resourceType ?? 'workflow' + /** + * Only the workflow tree interleaves folders and resources in one user-ordered list, so + * only it derives the optimistic placement from the workflows too. The other trees are + * ordered by the folder rows alone — mirroring `nextFolderSortOrder`, which consults a + * resource's sort column only when the config declares one. Feeding workflow sort orders + * into a knowledge-base or table folder would place it against an unrelated ordering + * space and flicker until the server response replaced it. + */ + const currentWorkflows = + resourceType === 'workflow' + ? Object.fromEntries(getWorkflows(variables.workspaceId).map((w) => [w.id, w])) + : {} return { id: tempId, @@ -229,7 +219,7 @@ export function useCreateFolder() { userId: '', workspaceId: variables.workspaceId, parentId: variables.parentId || null, - resourceType: variables.resourceType ?? 'workflow', + resourceType, locked: false, sortOrder: variables.sortOrder ?? diff --git a/apps/sim/hooks/queries/kb/knowledge.ts b/apps/sim/hooks/queries/kb/knowledge.ts index 38055a942eb..118b81ba5e5 100644 --- a/apps/sim/hooks/queries/kb/knowledge.ts +++ b/apps/sim/hooks/queries/kb/knowledge.ts @@ -46,7 +46,11 @@ import { } from '@/lib/api/contracts/knowledge' import type { ChunkingStrategy, StrategyOptions } from '@/lib/chunkers/types' import type { DocumentSortField, SortOrder } from '@/lib/knowledge/documents/types' -import { type KnowledgeQueryScope, knowledgeKeys } from '@/hooks/queries/utils/knowledge-keys' +import { + KNOWLEDGE_BASE_LIST_STALE_TIME, + type KnowledgeQueryScope, + knowledgeKeys, +} from '@/hooks/queries/utils/knowledge-keys' const logger = createLogger('KnowledgeQueries') @@ -59,7 +63,6 @@ export type { TagUsageData, } -export const KNOWLEDGE_BASE_LIST_STALE_TIME = 60 * 1000 export const KNOWLEDGE_BASE_DETAIL_STALE_TIME = 60 * 1000 export const KNOWLEDGE_DOCUMENT_DETAIL_STALE_TIME = 60 * 1000 export const KNOWLEDGE_DOCUMENT_LIST_STALE_TIME = 60 * 1000 diff --git a/apps/sim/hooks/queries/tables.ts b/apps/sim/hooks/queries/tables.ts index 78595ef2e06..3922ea555db 100644 --- a/apps/sim/hooks/queries/tables.ts +++ b/apps/sim/hooks/queries/tables.ts @@ -636,9 +636,6 @@ export function useUpdateTableLocks(workspaceId: string) { }) } -/** - * Delete a table from a workspace. - */ /** * Move a table into a folder, or to the workspace root with `folderId: null`. * @@ -679,6 +676,9 @@ export function useMoveTable(workspaceId: string) { }) } +/** + * Delete a table from a workspace. + */ export function useDeleteTable(workspaceId: string) { const queryClient = useQueryClient() diff --git a/apps/sim/hooks/queries/utils/folder-keys.ts b/apps/sim/hooks/queries/utils/folder-keys.ts index 5d2d724b64c..48ed18904b9 100644 --- a/apps/sim/hooks/queries/utils/folder-keys.ts +++ b/apps/sim/hooks/queries/utils/folder-keys.ts @@ -1,7 +1,35 @@ -import type { FolderResourceType } from '@/lib/api/contracts/folders' +import type { FolderApi, FolderResourceType } from '@/lib/api/contracts/folders' +import type { WorkflowFolder } from '@/stores/folders/types' export type FolderQueryScope = 'active' | 'archived' +export const FOLDER_LIST_STALE_TIME = 60 * 1000 + +/** + * Maps a wire folder row to the client `WorkflowFolder` shape (string dates → `Date`). + * + * Lives beside the keys rather than in the hooks module so a server prefetch can hydrate a + * folder list without importing `@/hooks/queries/folders`, which drags the contracts barrel and + * the optimistic-mutation machinery in with it. Fields are listed explicitly, not spread, so a + * new wire field cannot silently enter the cached shape and diverge a hydrated entry from a + * client fetch. + */ +export function mapFolder(folder: FolderApi): WorkflowFolder { + return { + id: folder.id, + name: folder.name, + userId: folder.userId, + workspaceId: folder.workspaceId, + parentId: folder.parentId, + resourceType: folder.resourceType, + locked: folder.locked, + sortOrder: folder.sortOrder, + createdAt: new Date(folder.createdAt), + updatedAt: new Date(folder.updatedAt), + deletedAt: folder.deletedAt ? new Date(folder.deletedAt) : null, + } +} + /** * `resourceType` is part of the key, not an implicit default, because one workspace holds * an independent folder tree per resource. Without it the Knowledge, Tables, and Workflows diff --git a/apps/sim/hooks/queries/utils/knowledge-keys.ts b/apps/sim/hooks/queries/utils/knowledge-keys.ts index 6eb039e052d..12edbd76717 100644 --- a/apps/sim/hooks/queries/utils/knowledge-keys.ts +++ b/apps/sim/hooks/queries/utils/knowledge-keys.ts @@ -12,6 +12,9 @@ import type { KnowledgeScope } from '@/lib/api/contracts/knowledge/base' */ export type KnowledgeQueryScope = KnowledgeScope +/** Shared with the server prefetch so a hydrated list and a client fetch never disagree. */ +export const KNOWLEDGE_BASE_LIST_STALE_TIME = 60 * 1000 + export const knowledgeKeys = { all: ['knowledge'] as const, lists: () => [...knowledgeKeys.all, 'list'] as const, diff --git a/apps/sim/lib/folders/lifecycle.test.ts b/apps/sim/lib/folders/lifecycle.test.ts index d6a7ee2f038..94074a09095 100644 --- a/apps/sim/lib/folders/lifecycle.test.ts +++ b/apps/sim/lib/folders/lifecycle.test.ts @@ -310,6 +310,20 @@ describe('updateFolder', () => { expect(set.parentId).toBe('parent-1') }) + it('refuses an archived folder, so a delete cannot unlock its locked subfolders', async () => { + /** + * `getFolderLockStatus` skips archived rows, so an archived-but-locked folder reports + * unlocked. Without the `deletedAt IS NULL` predicate in the UPDATE, deleting a parent + * would make every locked subfolder under it freely renameable and reparentable. + */ + dbChainMockFns.returning.mockResolvedValueOnce([]) + + const result = await updateFolder({ ...baseUpdate, name: 'Renamed' }) + + expect(result).toMatchObject({ success: false, errorCode: 'not_found' }) + expect(dbChainMockFns.set).toHaveBeenCalled() + }) + it('clears the parent when reparenting to the root', async () => { dbChainMockFns.returning.mockResolvedValueOnce([folderRow()]) diff --git a/apps/sim/lib/folders/lifecycle.ts b/apps/sim/lib/folders/lifecycle.ts index ea1657dfd06..7b02f7f4f1b 100644 --- a/apps/sim/lib/folders/lifecycle.ts +++ b/apps/sim/lib/folders/lifecycle.ts @@ -445,6 +445,11 @@ export async function restoreFolder(params: RestoreFolderParams): Promise -> = { +type RestorableFolderType = 'folder' | 'knowledge_folder' | 'table_folder' + +/** + * Deliberately a total `Record` over the folder types, not a `Partial` one: adding a tree to + * `RestorableFolderType` without a mapping here has to fail the build. With a partial map the + * lookup would yield `undefined`, `performRestoreFolder` would fall back to its `'workflow'` + * default, and the restore would silently target the wrong tree. + */ +const FOLDER_RESOURCE_TYPE_BY_RESTORABLE: Record = { folder: 'workflow', knowledge_folder: 'knowledge_base', table_folder: 'table', diff --git a/apps/sim/lib/table/service.ts b/apps/sim/lib/table/service.ts index decdd159909..5d23ec56931 100644 --- a/apps/sim/lib/table/service.ts +++ b/apps/sim/lib/table/service.ts @@ -663,7 +663,6 @@ export async function moveTableToFolder( .returning({ name: userTableDefinitions.name, createdBy: userTableDefinitions.createdBy, - workspaceId: userTableDefinitions.workspaceId, }) if (result.length === 0) { @@ -674,7 +673,7 @@ export async function moveTableToFolder( const actorId = actingUserId ?? createdBy if (actorId) { recordAudit({ - workspaceId: workspaceId ?? null, + workspaceId, actorId, action: AuditAction.TABLE_UPDATED, resourceType: AuditResourceType.TABLE, From 87ffb4acb3159042eea3640264b55823e3378553 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 28 Jul 2026 22:18:24 -0700 Subject: [PATCH 08/11] fix(tables): re-read live placement before skipping a move as a no-op `handleMoveTable` and `handleMoveFolder` decided "already there, nothing to do" from `activeTable`/`activeFolder`, which are snapshots taken when the context menu opened. A refetch or a concurrent move since then makes that comparison stale, so the write the user just asked for is silently skipped and the row stays where it was. Both now compare against the live list, matching the knowledge-base move. --- .../workspace/[workspaceId]/tables/tables.tsx | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx b/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx index 5919b61707e..a4fee3b99de 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx @@ -743,7 +743,17 @@ export function Tables() { (optionValue: string) => { if (!activeTable) return const folderId = parseMoveOptionValue(optionValue) - if ((activeTable.folderId ?? null) === folderId) return + /** + * Placement is re-read from the live list rather than trusted from `activeTable`, which + * is a snapshot taken when the menu opened. A refetch or a concurrent move since then + * would make the no-op check compare against a stale location and skip a write the user + * asked for. Matches the knowledge-base move. + */ + const current = tablesRef.current.find((table) => table.id === activeTable.id) ?? activeTable + if ((current.folderId ?? null) === folderId) { + closeRowContextMenu() + return + } moveTable.mutate({ tableId: activeTable.id, folderId }) closeRowContextMenu() }, @@ -770,11 +780,12 @@ export function Tables() { (optionValue: string) => { if (!activeFolder) return const parentId = parseMoveOptionValue(optionValue) - if ((activeFolder.parentId ?? null) === parentId) return - moveFolderTo(activeFolder.id, parentId) + // Same reasoning as `handleMoveTable`: compare against the live row, not the snapshot. + const current = folderById.get(activeFolder.id) ?? activeFolder + if ((current.parentId ?? null) !== parentId) moveFolderTo(activeFolder.id, parentId) closeRowContextMenu() }, - [activeFolder, moveFolderTo, closeRowContextMenu] + [activeFolder, folderById, moveFolderTo, closeRowContextMenu] ) const rowDragDropConfig = useFolderRowDragDrop({ From b1c2a3390b7baa8d824130d33c4206bc299240f9 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 28 Jul 2026 22:20:31 -0700 Subject: [PATCH 09/11] chore(folders): drop production figures from migration and code comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This repository is public. The 0274 header and the Recently Deleted policy comment carried concrete counts and ratios taken from the production database, which is operational detail that does not belong in open source. Both now state the property that matters — what was asserted, and why disabled-on-restore is the safe default — without the underlying numbers. --- .../recently-deleted/recently-deleted.tsx | 6 ++--- .../0274_file_folder_cutover_reconcile.sql | 23 ++++++++++--------- 2 files changed, 15 insertions(+), 14 deletions(-) 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 2cfaae3383f..feb46c11b97 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 @@ -165,9 +165,9 @@ const FOLDER_TREE_TAB_BY_TYPE = new Map( /** * Restore brings a workflow back but deliberately does NOT re-enable its schedules, webhooks, * or chats. Archive overwrites their enabled state without recording what it was, so restoring - * to a constant would re-enable something the user had switched off — on production roughly - * half of live schedules sit at `disabled` and ~12% of live webhooks at `isActive: false`, so - * that is the common case, not the edge case. + * to a constant would re-enable something the user had deliberately switched off — and a + * deliberately-disabled schedule or webhook is a common state, not an edge case, so that would + * be wrong more often than right. * * Leaving it off is the safe choice; leaving it UNSAID is not. Restoring anything that carries * automations says so at the moment of restore, so re-enabling is a known step rather than a diff --git a/packages/db/migrations/0274_file_folder_cutover_reconcile.sql b/packages/db/migrations/0274_file_folder_cutover_reconcile.sql index cb8ad70608a..87fb0c5f0fc 100644 --- a/packages/db/migrations/0274_file_folder_cutover_reconcile.sql +++ b/packages/db/migrations/0274_file_folder_cutover_reconcile.sql @@ -23,18 +23,19 @@ -- after this runs. The block is idempotent and safe to repeat; a journaled migration does not -- replay, so the post-drain pass is an operational step, not an automatic one. -- --- On an environment where 0272 has not yet run — production at time of writing — 0272 and this --- file apply back to back and this is a no-op reconcile over the rows 0272 just wrote. +-- Where 0272 has not yet run, it and this file apply back to back and this becomes a no-op +-- reconcile over the rows 0272 just wrote. -- --- Dry-run against production (read-only), materialising the full post-0272 `folder` table from --- both source tables and checking every constraint the table declares: 34,991 rows, and 0 of --- each of — NULL names, NULL required columns, primary-key collisions between the two source --- tables, `folder_workspace_resource_parent_name_active_unique` violations, resource-type or --- workspace violations of `folder_parent_resource_type_match`, and parent/user/workspace FK --- violations. Longest generated name 55 chars. 0272's dedup renames 47 workflow folders and 0 --- file folders (the legacy table's own active-unique index guarantees the latter, which is why --- pass 2 below can write raw names). All 5,475 foldered files and 4,593 foldered workflows keep --- a resolvable folder id. +-- Validated with a read-only dry run that materialises the full post-0272 `folder` table from +-- both source tables and asserts every constraint the table declares: no NULL names or NULL +-- required columns, no primary-key collisions between the two source tables, no +-- `folder_workspace_resource_parent_name_active_unique` violations, no resource-type or +-- workspace violations of `folder_parent_resource_type_match`, and no parent/user/workspace FK +-- violations, with every foldered file and workflow keeping a resolvable folder id. +-- +-- 0272's dedup renames only workflow folders, never file folders: `workspace_file_folders` +-- enforces the same active-unique key this table does, so no file folder can arrive duplicated. +-- That is what lets pass 2 below write raw names. -- Wrapped in a DO block so the two passes are ONE statement and therefore atomic on their own. -- 0272 ends with an embedded COMMIT (its trailing CONCURRENTLY index builds cannot run inside a From 7f0d9352a7d80314bf1b5808b67f8fbb83804935 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 28 Jul 2026 22:48:38 -0700 Subject: [PATCH 10/11] fix(folders): close the findings from auditing the whole release landing unit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audited as the unit that actually ships — #6014 (pinning), #6025 (workflow folders onto the generic table), #6037 (the engine) and this branch together against `main`, rather than this branch against `staging`. Authorization - `PUT /api/folders/reorder` still operated on archived folders. `getFolderLockStatus` skips archived rows, so `assertFolderMutable` there was a guaranteed no-op — meaning a locked folder became freely reparentable the moment its parent was deleted. This branch closed exactly that hole on `PUT /api/folders/[id]` and left its sibling open, then widened reorder to three resource trees. Reordering an archived folder is also wrong on its own terms: `collectArchivedSubtreeIds` walks the cascade by parent, so moving a branch out of an archived subtree silently drops it from that folder's restore Data loss in the purge - `batchDeleteByWorkspaceAndTimestamp` forwarded its eligibility predicate only into the SELECT, never the DELETE. The `folder` target runs through it, so a restore committing between the two statements had its folder hard-deleted anyway — taking the placement of the children the restore had just brought back. The workflow purge re-checks for precisely this reason and the knowledge-base sweep gained the same guard earlier in this branch; `folder` had neither. The wrapper now re-asserts eligibility on the DELETE for every caller Wire boundary - `toPinnedItemApi` had no return type, so `pinned_item.resource_type` — plain `text` by design, against a closed contract enum — was never checked. Annotating it surfaced the real hole immediately: during a rolling deploy an older pod can read a pin a newer one wrote, and returning it would fail response validation and take the WHOLE list down rather than the one row. Unknown kinds are now narrowed out explicitly at the boundary instead of surviving by accident on a filter whose stated job is something else Interaction - The knowledge-base FOLDER move still compared against the snapshot taken when the menu opened. The resource move on that page and both Tables handlers were fixed earlier in this branch; this was the last one Operational - 0274's "re-run after drain" instruction needed a precondition. Cleanup hard-deletes from `folder` but never from `workspace_file_folders`, so a re-run after a purge would reinstate every purged file folder as a soft-deleted phantom whose files are already gone. Retention is far longer than any drain, so running it promptly is sufficient — but the instruction said nothing about ordering Accuracy - Four comments named symbols that do not exist or no longer apply: `useFolderCreateWithDedup` (never existed — it is `nextUntitledFolderName`), `collectActiveSubtreeIds`, `restoreFolderCascade` as the live restore path, and a claim that a server `createSearchParamsCache` reads the shared folder param. The shared param doc also now admits Files declares its own `?folderId=` rather than claiming to be the single declaration - `FOLDER_RESOURCES.file` is unreachable at runtime — `servedFolderResourceTypeSchema` does not serve `'file'` — and a reader would reasonably assume otherwise. Says so, and says what routing file folders through the generic engine would bypass - `pinnedItem.resourceType`'s inline comment was missing `'folder'` - `folders.test.ts` fixtures still carried `color` and `isExpanded`, both deliberately dropped from the generic table — pre-consolidation rows masquerading as folders - `getFolders` in the folder cache had one caller, in its own file --- apps/sim/app/api/folders/reorder/route.ts | 26 +++++++++-- apps/sim/app/api/pinned-items/route.ts | 44 ++++++++++++++++--- .../components/folders/search-params.ts | 10 +++-- .../[workspaceId]/knowledge/knowledge.tsx | 6 ++- apps/sim/hooks/queries/folders.test.ts | 4 -- apps/sim/hooks/queries/utils/folder-cache.ts | 2 +- apps/sim/lib/cleanup/batch-delete.ts | 17 +++++-- apps/sim/lib/folders/cascade.ts | 2 +- apps/sim/lib/folders/config.ts | 10 ++++- apps/sim/lib/folders/naming.ts | 2 +- .../0274_file_folder_cutover_reconcile.sql | 6 +++ packages/db/schema.ts | 2 +- 12 files changed, 105 insertions(+), 26 deletions(-) diff --git a/apps/sim/app/api/folders/reorder/route.ts b/apps/sim/app/api/folders/reorder/route.ts index a1e531be85b..b27dbdabe37 100644 --- a/apps/sim/app/api/folders/reorder/route.ts +++ b/apps/sim/app/api/folders/reorder/route.ts @@ -3,7 +3,7 @@ import { folder as folderTable } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { assertFolderMutable, FolderLockedError } from '@sim/platform-authz/workflow' import { getPostgresErrorCode } from '@sim/utils/errors' -import { and, eq, inArray } from 'drizzle-orm' +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' @@ -38,10 +38,24 @@ export const PUT = withRouteHandler(async (req: NextRequest) => { } const folderIds = updates.map((u) => u.id) + /** + * Archived folders are excluded here for the same reason `PUT /api/folders/[id]` excludes + * them: `getFolderLockStatus` skips archived rows, so `assertFolderMutable` below is a + * guaranteed no-op on one — meaning a locked folder becomes freely reparentable the moment + * its parent is deleted. Reordering an archived folder is also a correctness problem in its + * own right: `collectArchivedSubtreeIds` walks the cascade by parent, so moving a branch out + * of an archived subtree silently drops it from that folder's restore. + */ const existingFolders = await db .select({ id: folderTable.id, workspaceId: folderTable.workspaceId }) .from(folderTable) - .where(and(inArray(folderTable.id, folderIds), eq(folderTable.resourceType, resourceType))) + .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) @@ -138,7 +152,13 @@ export const PUT = withRouteHandler(async (req: NextRequest) => { await tx .update(folderTable) .set(updateData) - .where(and(eq(folderTable.id, update.id), eq(folderTable.resourceType, resourceType))) + .where( + and( + eq(folderTable.id, update.id), + eq(folderTable.resourceType, resourceType), + isNull(folderTable.deletedAt) + ) + ) } }) diff --git a/apps/sim/app/api/pinned-items/route.ts b/apps/sim/app/api/pinned-items/route.ts index b83a86da0cb..6a159df3eef 100644 --- a/apps/sim/app/api/pinned-items/route.ts +++ b/apps/sim/app/api/pinned-items/route.ts @@ -4,7 +4,12 @@ import { getPostgresErrorCode } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' -import { createPinnedItemContract, listPinnedItemsContract } from '@/lib/api/contracts' +import { + createPinnedItemContract, + listPinnedItemsContract, + type PinnedItemApi, + pinnedResourceTypeSchema, +} from '@/lib/api/contracts' import { parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -13,8 +18,22 @@ import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' const logger = createLogger('PinnedItemsAPI') -function toPinnedItemApi(row: typeof pinnedItem.$inferSelect) { - return { ...row, pinnedAt: row.pinnedAt.toISOString() } +/** + * Narrows a stored row to the wire shape, dropping any row whose `resourceType` this build does + * not recognise. + * + * `pinned_item.resource_type` is plain `text` — deliberately, so the set of pinnable kinds can + * grow — while the contract is a closed enum. During a rolling deploy an older pod can therefore + * read a pin a newer one wrote. Returning it would fail response validation and take the WHOLE + * list down rather than the single row, so the unknown kind is skipped instead. + * + * `filterToActiveResources` already drops these as a side effect of not having a table to look + * them up in; this makes the guarantee explicit and compiler-checked at the wire boundary. + */ +function toPinnedItemApi(row: typeof pinnedItem.$inferSelect): PinnedItemApi | null { + const resourceType = pinnedResourceTypeSchema.safeParse(row.resourceType) + if (!resourceType.success) return null + return { ...row, resourceType: resourceType.data, pinnedAt: row.pinnedAt.toISOString() } } /** Lists the session user's pinned items in a workspace, optionally filtered to one `resourceType`. */ @@ -46,7 +65,11 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const activeRows = await filterToActiveResources(rows, workspaceId) - return NextResponse.json({ pinnedItems: activeRows.map(toPinnedItemApi) }) + const pinnedItems = activeRows + .map(toPinnedItemApi) + .filter((item): item is PinnedItemApi => item !== null) + + return NextResponse.json({ pinnedItems }) }) /** Pins a resource for the session user. Idempotent from the client's perspective: re-pinning returns 409. */ @@ -81,7 +104,18 @@ export const POST = withRouteHandler(async (request: NextRequest) => { }) .returning() - return NextResponse.json({ pinnedItem: toPinnedItemApi(created) }, { status: 201 }) + /** + * Built from the validated `resourceType` rather than round-tripping the raw row through + * `toPinnedItemApi`: the value came from the contract enum, so narrowing here could never + * fail, and threading a `| null` through a path that cannot produce one would only obscure + * that. + */ + const pinned: PinnedItemApi = { + ...created, + resourceType, + pinnedAt: created.pinnedAt.toISOString(), + } + return NextResponse.json({ pinnedItem: pinned }, { status: 201 }) } catch (error) { if (getPostgresErrorCode(error) === '23505') { return NextResponse.json({ error: 'This item is already pinned' }, { status: 409 }) diff --git a/apps/sim/app/workspace/[workspaceId]/components/folders/search-params.ts b/apps/sim/app/workspace/[workspaceId]/components/folders/search-params.ts index 2ba2c0818f7..ceaca0e9b4d 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/folders/search-params.ts +++ b/apps/sim/app/workspace/[workspaceId]/components/folders/search-params.ts @@ -1,10 +1,12 @@ import { parseAsString } from 'nuqs/server' /** - * The open folder on any resource list built on the generic folder engine - * (`workflow` / `file` / `knowledge_base` / `table`). Declared once here rather than per - * feature so every foldered surface shares the same URL key and semantics, and so - * `useFolderNavigation` and a server `createSearchParamsCache` can read the same parser. + * The open folder on a resource list built on the generic folder engine. Declared here rather + * than per feature so the surfaces that use the engine share one URL key and one set of + * semantics, and so a server `createSearchParamsCache` could read the same parser. + * + * Files declares its own `?folderId=` in `files/search-params.ts` — same key, same meaning, but + * it predates this module and has not been converged. * * Deliberately nullable (no `.withDefault`): a clean URL means the workspace root, which is * the only sane default and keeps a shared link short. diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx index 5cd831e02e1..922375b4cc4 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx @@ -830,7 +830,11 @@ export function Knowledge() { const folder = activeFolderRef.current if (!folder) return const parentId = parseMoveOptionValue(optionValue) - if ((folder.parentId ?? null) !== parentId) await moveFolderTo(folder.id, parentId) + // Live placement, not the snapshot taken when the menu opened — a refetch or concurrent + // move in between would otherwise skip the write the user just chose. Matches the + // knowledge-base move below and both Tables handlers. + const current = foldersRef.current.find((item) => item.id === folder.id) ?? folder + if ((current.parentId ?? null) !== parentId) await moveFolderTo(folder.id, parentId) closeFolderContextMenu() }, [moveFolderTo, closeFolderContextMenu] 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/utils/folder-cache.ts b/apps/sim/hooks/queries/utils/folder-cache.ts index b3a43472ba8..5d41677cab8 100644 --- a/apps/sim/hooks/queries/utils/folder-cache.ts +++ b/apps/sim/hooks/queries/utils/folder-cache.ts @@ -5,7 +5,7 @@ import type { WorkflowFolder } from '@/stores/folders/types' const EMPTY_FOLDERS: WorkflowFolder[] = [] -export function getFolders( +function getFolders( workspaceId: string, resourceType: FolderResourceType = 'workflow' ): WorkflowFolder[] { diff --git a/apps/sim/lib/cleanup/batch-delete.ts b/apps/sim/lib/cleanup/batch-delete.ts index 10a04b3978a..fd4406f6d8e 100644 --- a/apps/sim/lib/cleanup/batch-delete.ts +++ b/apps/sim/lib/cleanup/batch-delete.ts @@ -238,19 +238,28 @@ export async function batchDeleteByWorkspaceAndTimestamp({ dbClient = db, ...rest }: BatchDeleteOptions): Promise { + /** + * Re-asserted on the DELETE, not just the SELECT. Every row here is soft-deleted and past + * retention, so a restore committing between the two statements is exactly the case that must + * not be hard-deleted — and for `folder` that would take the placement of the children the + * restore had just brought back with it. Rebuilt rather than reused from `selectChunk` because + * the id list already scopes the statement; only the eligibility half is re-checked. + */ + const eligibility = [lt(timestampCol, retentionDate)] + if (requireTimestampNotNull) eligibility.push(isNotNull(timestampCol)) + if (additionalPredicate) eligibility.push(additionalPredicate) + return chunkedBatchDelete({ tableDef, workspaceIds, tableName, dbClient, + deleteFilter: and(...eligibility), selectChunk: (chunkIds, limit) => { - const predicates = [inArray(workspaceIdCol, chunkIds), lt(timestampCol, retentionDate)] - if (requireTimestampNotNull) predicates.push(isNotNull(timestampCol)) - if (additionalPredicate) predicates.push(additionalPredicate) return dbClient .select({ id: sql`id` }) .from(tableDef) - .where(and(...predicates)) + .where(and(inArray(workspaceIdCol, chunkIds), ...eligibility)) .limit(limit) }, ...rest, diff --git a/apps/sim/lib/folders/cascade.ts b/apps/sim/lib/folders/cascade.ts index fa730b17dec..3e72aa4fd02 100644 --- a/apps/sim/lib/folders/cascade.ts +++ b/apps/sim/lib/folders/cascade.ts @@ -53,7 +53,7 @@ export async function collectCascadeSubtreeIds( * * Matching on the exact `timestamp` is what stops a restore from also reviving folders * that were archived independently before or after — and is why this cannot reuse - * {@link collectActiveSubtreeIds}, which by definition cannot see an archived subtree. + * {@link collectCascadeSubtreeIds}, which by definition cannot see an archived subtree. */ export async function collectArchivedSubtreeIds( tx: DbOrTx, diff --git a/apps/sim/lib/folders/config.ts b/apps/sim/lib/folders/config.ts index 542901b789d..4c6b3d6333e 100644 --- a/apps/sim/lib/folders/config.ts +++ b/apps/sim/lib/folders/config.ts @@ -385,7 +385,7 @@ export const FOLDER_RESOURCES: Record ({ archivedAt: timestamp, updatedAt: now }) satisfies Partial, sortOrderColumn: workflow.sortOrder, /** - * Restored in bulk rather than through a `restoreChildren` hook. `restoreFolderCascade` + * Restored in bulk rather than through a `restoreChildren` hook. `restoreFolderChildren` * already matches these on the archive timestamp, so a webhook or chat the user archived * independently stays archived — and it does so in a fixed number of statements inside the * restore transaction. Routing them through `restoreWorkflow` instead would add a @@ -438,6 +438,14 @@ export const FOLDER_RESOURCES: Record archiveChildren: archiveWorkflowChildren, guardDelete: guardLastWorkflows, }, + /** + * Present to satisfy the total `Record`, but UNREACHABLE at runtime: + * `servedFolderResourceTypeSchema` does not serve `'file'`, and the Files surface goes through + * `workspace-file-folder-manager.ts` instead. Do not treat this entry as a live path — routing + * file folders through the generic engine would bypass the `workspace_file_folders:` advisory + * lock that makes its check-then-write pairs atomic, and the name rules that keep a folder name + * usable as a path segment. + */ file: { resourceType: 'file', label: 'file', diff --git a/apps/sim/lib/folders/naming.ts b/apps/sim/lib/folders/naming.ts index 4649c2cedb8..3f8d66738e6 100644 --- a/apps/sim/lib/folders/naming.ts +++ b/apps/sim/lib/folders/naming.ts @@ -16,7 +16,7 @@ type DbOrTx = Pick * instead where they do (create, rename). * * The `" (N)"` shape deliberately matches both the client-side dedup in - * `useFolderCreateWithDedup` and the backfill in migration 0272, so a deduped name reads the + * `nextUntitledFolderName` and the backfill in migration 0272, so a deduped name reads the * same however it was produced. */ export async function deduplicateFolderName( diff --git a/packages/db/migrations/0274_file_folder_cutover_reconcile.sql b/packages/db/migrations/0274_file_folder_cutover_reconcile.sql index 87fb0c5f0fc..8730b273bb8 100644 --- a/packages/db/migrations/0274_file_folder_cutover_reconcile.sql +++ b/packages/db/migrations/0274_file_folder_cutover_reconcile.sql @@ -23,6 +23,12 @@ -- after this runs. The block is idempotent and safe to repeat; a journaled migration does not -- replay, so the post-drain pass is an operational step, not an automatic one. -- +-- One precondition on that re-run: do it BEFORE the soft-delete cleanup job has had a chance to +-- purge expired `folder` rows. Cleanup hard-deletes from `folder` but never from +-- `workspace_file_folders`, so re-running afterwards would reinstate every purged file folder as +-- a soft-deleted phantom in Recently Deleted whose files are already gone. The retention window +-- is far longer than any drain, so running the re-run promptly after the deploy is sufficient. +-- -- Where 0272 has not yet run, it and this file apply back to back and this becomes a no-op -- reconcile over the rows 0272 just wrote. -- diff --git a/packages/db/schema.ts b/packages/db/schema.ts index e0bf19321e4..11672d356aa 100644 --- a/packages/db/schema.ts +++ b/packages/db/schema.ts @@ -209,7 +209,7 @@ export const pinnedItem = pgTable( workspaceId: text('workspace_id') .notNull() .references(() => workspace.id, { onDelete: 'cascade' }), - resourceType: text('resource_type').notNull(), // 'workflow' | 'file' | 'knowledge_base' | 'table' + resourceType: text('resource_type').notNull(), // 'workflow' | 'file' | 'knowledge_base' | 'table' | 'folder' resourceId: text('resource_id').notNull(), pinnedAt: timestamp('pinned_at').notNull().defaultNow(), }, From 3ad97f03749aca5ea7dc57c96e5e4bd3167264db Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 28 Jul 2026 23:01:49 -0700 Subject: [PATCH 11/11] fix(folders): gate the orphan fallback on a resolved folder index, not a loading flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The URL heal was fixed to use `isSuccess && !isPlaceholderData`, but the list filters that decide "this resource's folderId does not resolve, so show it at the root" were left on `isLoading` — the same footgun, one layer down. `isLoading` is false for a disabled query (no workspaceId), false for an errored one, and — because `useFolders` sets `keepPreviousData` — false while the previous workspace's folders are still on screen during a switch. In all three the index is empty or belongs to another workspace, so every foldered knowledge base and table was treated as an orphan and dragged to the root, or filtered out of a deep-linked folder entirely. A transient fetch failure was enough to make a folder look empty. `useFolderNavigation` now exposes `foldersResolved` instead of `isLoading`, so the heal and both list filters share one signal and the footgun is no longer reachable from the hook's surface. --- .../folders/use-folder-navigation.ts | 23 +++++++++++----- .../[workspaceId]/knowledge/knowledge.tsx | 26 ++++++++----------- .../workspace/[workspaceId]/tables/tables.tsx | 12 +++++---- 3 files changed, 34 insertions(+), 27 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/components/folders/use-folder-navigation.ts b/apps/sim/app/workspace/[workspaceId]/components/folders/use-folder-navigation.ts index 8a94b0fc093..71a9d01e9e3 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/folders/use-folder-navigation.ts +++ b/apps/sim/app/workspace/[workspaceId]/components/folders/use-folder-navigation.ts @@ -29,7 +29,16 @@ export interface FolderNavigation { /** Every active folder in this resource's tree, as returned by the folders API. */ folders: WorkflowFolder[] folderById: Map - isLoading: boolean + /** + * Whether `folders`/`folderById` can be trusted to be the COMPLETE set for this workspace. + * + * Deliberately exposed instead of `isLoading`, which is a footgun here: it is false for a + * disabled query (no `workspaceId`), false for an errored one, and — because `useFolders` + * sets `keepPreviousData` — false while the previous workspace's folders are still on screen + * during a switch. A caller deciding "this resource's `folderId` does not resolve, so treat it + * as an orphan" off `isLoading` would dump every foldered row at the root in all three. + */ + foldersResolved: boolean } const EMPTY_FOLDERS: WorkflowFolder[] = [] @@ -54,7 +63,6 @@ export function useFolderNavigation({ const { data: folders = EMPTY_FOLDERS, - isLoading, isSuccess, isPlaceholderData, } = useFolders(workspaceId, { resourceType }) @@ -67,7 +75,7 @@ export function useFolderNavigation({ * folders during a workspace switch. In all three the list is empty or stale, and healing * off it would throw away a perfectly good folder. */ - const foldersAreAuthoritative = isSuccess && !isPlaceholderData + const foldersResolved = isSuccess && !isPlaceholderData const setCurrentFolderId = useCallback( (folderId: string | null) => { @@ -92,10 +100,11 @@ export function useFolderNavigation({ * and upload actions keep targeting that id, so a new resource is filed somewhere nothing * can reach. * - * Waits for `isLoading` so an empty index mid-fetch never evicts a perfectly good id. + * Gated on {@link FolderNavigation.foldersResolved} so an empty or stale index never evicts a + * perfectly good id. */ useEffect(() => { - if (!foldersAreAuthoritative || !currentFolderId || folderById.has(currentFolderId)) return + if (!foldersResolved || !currentFolderId || folderById.has(currentFolderId)) return /** * `history: 'replace'`, overriding the `push` these params default to. Opening a folder is * a navigation and belongs in the back stack; correcting a URL that never pointed anywhere @@ -103,7 +112,7 @@ export function useFolderNavigation({ * and pushes again, so Back never escapes the page. */ void setFolderParams({ folderId: null }, { history: 'replace' }) - }, [foldersAreAuthoritative, currentFolderId, folderById, setFolderParams]) + }, [foldersResolved, currentFolderId, folderById, setFolderParams]) const breadcrumbs = useMemo(() => { if (!currentFolderId) return EMPTY_FOLDERS @@ -135,6 +144,6 @@ export function useFolderNavigation({ breadcrumbs, folders, folderById, - isLoading, + foldersResolved, } } diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx index 922375b4cc4..5dfe44bff34 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx @@ -193,17 +193,11 @@ export function Knowledge() { const { mutateAsync: updateKnowledgeBaseMutation } = useUpdateKnowledgeBase(workspaceId) const { mutateAsync: deleteKnowledgeBaseMutation } = useDeleteKnowledgeBase(workspaceId) - const { - currentFolderId, - setCurrentFolderId, - breadcrumbs, - folders, - folderById, - isLoading: foldersLoading, - } = useFolderNavigation({ - resourceType: FOLDER_RESOURCE_TYPE, - workspaceId, - }) + const { currentFolderId, setCurrentFolderId, breadcrumbs, folders, folderById, foldersResolved } = + useFolderNavigation({ + resourceType: FOLDER_RESOURCE_TYPE, + workspaceId, + }) const createFolder = useCreateFolder() const updateFolder = useUpdateFolder() @@ -432,13 +426,15 @@ export function Knowledge() { * A `folderId` that no longer names an active folder — a base restored on its own out of * Recently Deleted while its folder stayed archived, or a cascade that failed partway — * would otherwise match no level at all and leave the base unreachable from every view. - * Fall it back to the root instead. Skipped while the folder list is still loading, when - * an empty index would transiently drag every base to the root. + * Fall it back to the root instead — but only once `foldersResolved` says the index is the + * complete set for THIS workspace. Gating on a loading flag instead would treat an errored + * fetch, a disabled query, or the previous workspace's cached folders as "no such folder" + * and drag every foldered base to the root. */ let result = filterKnowledgeBases(knowledgeBases, debouncedSearchQuery).filter((kb) => { const folderId = kb.folderId ?? null const effectiveFolderId = - foldersLoading || !folderId || folderById.has(folderId) ? folderId : null + !foldersResolved || !folderId || folderById.has(folderId) ? folderId : null return effectiveFolderId === currentFolderId }) @@ -507,7 +503,7 @@ export function Knowledge() { knowledgeBases, currentFolderId, folderById, - foldersLoading, + foldersResolved, debouncedSearchQuery, connectorFilter, contentFilter, diff --git a/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx b/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx index a4fee3b99de..94eecb940bd 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx @@ -127,7 +127,7 @@ export function Tables() { breadcrumbs: folderChain, folders, folderById, - isLoading: foldersLoading, + foldersResolved, } = useFolderNavigation({ resourceType: 'table', workspaceId, @@ -302,13 +302,15 @@ export function Tables() { * A `folderId` that no longer names an active folder — restored on its own out * of Recently Deleted while its folder stayed archived — would otherwise match * no level at all and leave the table unreachable from every view. Fall it back - * to the root instead. Skipped while the folder list is still loading, when an - * empty index would transiently drag every table to the root. + * to the root instead — but only once `foldersResolved` says the index is the complete + * set for THIS workspace. Gating on a loading flag instead would treat an errored fetch, + * a disabled query, or the previous workspace's cached folders as "no such folder" and + * drag every foldered table to the root. */ let result = tables.filter((t) => { const folderId = t.folderId ?? null const effectiveFolderId = - foldersLoading || !folderId || folderById.has(folderId) ? folderId : null + !foldersResolved || !folderId || folderById.has(folderId) ? folderId : null return effectiveFolderId === currentFolderId }) if (query) result = result.filter((t) => t.name.toLowerCase().includes(query)) @@ -361,7 +363,7 @@ export function Tables() { tables, currentFolderId, folderById, - foldersLoading, + foldersResolved, debouncedSearchTerm, rowCountFilter, ownerFilter,