diff --git a/apps/sim/app/api/folders/[id]/duplicate/route.ts b/apps/sim/app/api/folders/[id]/duplicate/route.ts index 2e481ec551c..9cd730013ae 100644 --- a/apps/sim/app/api/folders/[id]/duplicate/route.ts +++ b/apps/sim/app/api/folders/[id]/duplicate/route.ts @@ -3,8 +3,9 @@ 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, 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 +13,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 +21,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 +72,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 +88,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,58 +106,55 @@ 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', - 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( @@ -183,41 +210,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 +259,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 +298,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 +309,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/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/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/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/api/table/[tableId]/route.test.ts b/apps/sim/app/api/table/[tableId]/route.test.ts new file mode 100644 index 00000000000..7f1f48243c7 --- /dev/null +++ b/apps/sim/app/api/table/[tableId]/route.test.ts @@ -0,0 +1,152 @@ +/** + * @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', + 'workspace-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', + 'workspace-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..03eaa4c7243 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,33 @@ 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 }) + } + 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. 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/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/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-breadcrumbs.ts b/apps/sim/app/workspace/[workspaceId]/components/folders/folder-breadcrumbs.ts new file mode 100644 index 00000000000..67e333a5c9b --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/folders/folder-breadcrumbs.ts @@ -0,0 +1,60 @@ +import type { ElementType } from 'react' +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?: 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 + /** The open folder is where you already are, so its crumb is not a navigation target. */ + items.push({ + label: folder.name, + 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..614786a1ed3 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/folders/folder-context-menu.tsx @@ -0,0 +1,121 @@ +'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 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 + * 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..34ab496419e --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/folders/folder-row.tsx @@ -0,0 +1,55 @@ +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 { + /** + * 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 + * the em-dash placeholder. + */ + cells?: Record + /** Column id carrying the name cell. Defaults to `'name'`. */ + nameColumnId?: string +} + +/** + * 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. + * + * 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 { pinned, cells, nameColumnId = 'name' } = options + + return { + id: folderRowId(folder.id), + cells: { + ...cells, + [nameColumnId]: { + icon: FOLDER_ICON, + label: folder.name, + pinned, + }, + }, + } +} 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..66f0b2e455a --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/folders/move-options.tsx @@ -0,0 +1,169 @@ +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. + * + * `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, + 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..ceaca0e9b4d --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/folders/search-params.ts @@ -0,0 +1,25 @@ +import { parseAsString } from 'nuqs/server' + +/** + * 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. + */ +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..71a9d01e9e3 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/folders/use-folder-navigation.ts @@ -0,0 +1,149 @@ +'use client' + +import { useCallback, useEffect, 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 + /** + * 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[] = [] + +/** + * 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, + 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 foldersResolved = isSuccess && !isPlaceholderData + + 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]) + + /** + * 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. + * + * Gated on {@link FolderNavigation.foldersResolved} so an empty or stale index never evicts a + * perfectly good id. + */ + useEffect(() => { + 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 + * 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' }) + }, [foldersResolved, currentFolderId, folderById, setFolderParams]) + + 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, + foldersResolved, + } +} 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..b6a746b2606 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/folders/use-folder-row-drag-drop.ts @@ -0,0 +1,202 @@ +'use client' + +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' + +/** + * 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, + } + + /** + * 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 + + 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) { + 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' + /** + * 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 + 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]/components/resource/resource.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx index ed960210aa6..707cfdaeb9f 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,16 @@ 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. + * + * 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 } export interface ResourceRow { @@ -488,9 +499,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 +528,13 @@ const CellContent = memo(function CellContent({ icon, label, content, editing }: {icon && {icon}} + {pinned && ( + + )} ) }) @@ -681,6 +706,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]/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..da8ac589312 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/files.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/files.tsx @@ -61,6 +61,11 @@ import { Resource, 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' @@ -74,7 +79,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, @@ -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 } @@ -505,6 +531,7 @@ export function Files() { name: { icon: , label: folder.name, + pinned: pinnedFolderIds.has(folder.id), }, size: { label: @@ -530,6 +557,7 @@ export function Files() { name: { icon: , label: file.name, + pinned: pinnedFileIds.has(file.id), }, size: { label: formatFileSize(file.size, { includeBytes: true }), @@ -547,7 +575,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 @@ -1378,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, @@ -1765,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]/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]/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]/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/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/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/knowledge.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx index 32930a56128..5dfe44bff34 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 { 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' 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,18 +166,43 @@ 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. + */ const pinnedBaseIds = usePinnedIds(workspaceId, 'knowledge_base') + const pinnedFolderIds = usePinnedIds(workspaceId, 'folder') 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, folderById, foldersResolved } = + useFolderNavigation({ + resourceType: FOLDER_RESOURCE_TYPE, + workspaceId, + }) + + const createFolder = useCreateFolder() + const updateFolder = useUpdateFolder() + const deleteFolder = useDeleteFolderMutation() + const [ { search: urlSearchQuery, @@ -205,6 +252,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 +279,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 +387,56 @@ 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) + /** + * 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 — 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 = + !foldersResolved || !folderId || folderById.has(folderId) ? folderId : null + return effectiveFolderId === currentFolderId + }) if (connectorFilter.length > 0) { result = result.filter((kb) => { @@ -322,8 +492,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 } @@ -331,62 +501,132 @@ export function Knowledge() { }) }, [ knowledgeBases, + currentFolderId, + folderById, + foldersResolved, debouncedSearchQuery, connectorFilter, contentFilter, ownerFilter, activeSort, - members, + membersById, 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, { + pinned: pinnedFolderIds.has(folder.id), + cells: { + documents: { label: EMPTY_CELL_PLACEHOLDER }, + tokens: { label: EMPTY_CELL_PLACEHOLDER }, + connectors: { label: EMPTY_CELL_PLACEHOLDER }, + created: timeCell(folder.createdAt), + owner: ownerCell(folder.userId, membersById), + 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, + pinned: pinnedBaseIds.has(kb.id), + }, + 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, membersById), + updated: timeCell(kb.updatedAt), + }, + } + }) + + return [...folderRows, ...knowledgeBaseRows] + }, [visibleFolders, processedKBs, membersById, pinnedFolderIds, pinnedBaseIds]) + + /** + * 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 +669,213 @@ 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, + }) + /** + * 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) + } 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) + toast.error(getErrorMessage(moveError, 'Failed to move knowledge base')) + } + }, + [updateKnowledgeBaseMutation] + ) + + const handleMoveFolder = useCallback( + async (optionValue: string) => { + const folder = activeFolderRef.current + if (!folder) return + const parentId = parseMoveOptionValue(optionValue) + // 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] + ) + + const handleMoveKnowledgeBase = useCallback( + async (optionValue: string) => { + const kb = activeKnowledgeBaseRef.current + if (!kb) return + const folderId = parseMoveOptionValue(optionValue) + // 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] + ) + + 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 +884,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 +1091,12 @@ export function Knowledge() { return ( <> - + @@ -634,7 +1117,9 @@ export function Knowledge() { position={listContextMenuPosition} onClose={closeListContextMenu} onAddKnowledgeBase={handleOpenCreateModal} + onAddFolder={handleCreateFolder} disableAdd={!canEdit} + disableAddFolder={createFolder.isPending || !canEdit} /> {activeKnowledgeBase && ( @@ -645,10 +1130,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 +1145,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]/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..996786083fd 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/prefetch.ts +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/prefetch.ts @@ -1,28 +1,47 @@ 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, 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 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]/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 e6df13416ca..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 @@ -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,44 @@ 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]) +) + +/** + * 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 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 + * 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' - return resource.type === activeTab + return resource.type === activeTab || FOLDER_TREE_TAB_BY_TYPE.get(resource.type) === activeTab } export function RecentlyDeleted() { @@ -170,6 +215,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 +241,8 @@ export function RecentlyDeleted() { foldersQuery.isLoading || tablesQuery.isLoading || knowledgeQuery.isLoading || + knowledgeFoldersQuery.isLoading || + tableFoldersQuery.isLoading || filesQuery.isLoading || workspaceFoldersQuery.isLoading || chatsQuery.isLoading @@ -196,6 +252,8 @@ export function RecentlyDeleted() { foldersQuery.error || tablesQuery.error || knowledgeQuery.error || + knowledgeFoldersQuery.error || + tableFoldersQuery.error || filesQuery.error || workspaceFoldersQuery.error || chatsQuery.error @@ -243,6 +301,30 @@ export function RecentlyDeleted() { }) } + /** + * 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, + 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 +362,8 @@ export function RecentlyDeleted() { foldersQuery.data, tablesQuery.data, knowledgeQuery.data, + knowledgeFoldersQuery.data, + tableFoldersQuery.data, filesQuery.data, workspaceFoldersQuery.data, chatsQuery.data, @@ -380,6 +464,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 @@ -467,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/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..196aa207bba 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/prefetch.ts +++ b/apps/sim/app/workspace/[workspaceId]/tables/prefetch.ts @@ -1,26 +1,43 @@ 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, folderKeys, mapFolder } 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..94eecb940bd 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,94 @@ 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() - if (error) { - logger.error('Failed to load tables:', error) - } + const { + currentFolderId, + setCurrentFolderId, + breadcrumbs: folderChain, + folders, + folderById, + foldersResolved, + } = useFolderNavigation({ + resourceType: 'table', + workspaceId, + }) + + /** + * 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) + const moveTable = useMoveTable(workspaceId) const uploadCsv = useUploadCsvToTable() const importCsvAsync = useImportCsvAsync() + const createFolder = useCreateFolder() + const updateFolder = useUpdateFolder() + const deleteFolder = useDeleteFolderMutation() + + const membersById = useMemo(() => { + const map = new Map() + for (const member of members ?? []) map.set(member.userId, member) + return map + }, [members]) - const tableRename = useInlineRename({ - onSave: (tableId, name) => renameTable.mutateAsync({ tableId, name }), + /** + * 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 +233,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 +250,70 @@ 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 + + /** + * 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 (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, 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() - 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 — 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 = + !foldersResolved || !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 +351,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 +361,44 @@ export function Tables() { }) }, [ tables, + currentFolderId, + folderById, + foldersResolved, 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, { + pinned: pinnedFolderIds.has(folder.id), + 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, + pinned: pinnedTableIds.has(table.id), }, columns: { icon: , @@ -234,19 +409,110 @@ 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, pinnedFolderIds, pinnedTableIds]) + + /** + * 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), + }, + { + 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]) + + 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 +557,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 +646,8 @@ export function Tables() { rowCountDisplayLabel, ownerDisplayLabel, hasActiveFilters, + setRowCountFilter, + setOwnerFilter, ] ) @@ -397,12 +664,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 +687,126 @@ 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, router, workspaceId] + [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 + }, + [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) + /** + * 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() + }, + // 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) + // 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, folderById, 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 +819,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 +899,7 @@ export function Tables() { try { const result = await importCsvAsync.mutateAsync({ workspaceId, + folderId: currentFolderId, file, onProgress: (percent) => { useImportTrayStore.getState().setUploadPercent(pendingId, percent) @@ -519,7 +930,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 +968,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 +989,7 @@ export function Tables() { try { const result = await createTableAsync({ name, + folderId: currentFolderId, schema: { columns: [{ name: 'name', type: 'string' }], }, @@ -586,7 +1002,30 @@ 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, + }) + /** + * 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, setSearchTerm, startFolderRename]) const headerActions: ResourceAction[] = useMemo( () => [ @@ -594,21 +1033,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 +1070,8 @@ export function Tables() { @@ -636,6 +1084,7 @@ export function Tables() { @@ -656,23 +1105,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 +1133,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 +1198,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/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/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 d486195cb5e..ccef446e784 100644 --- a/apps/sim/background/cleanup-soft-deletes.ts +++ b/apps/sim/background/cleanup-soft-deletes.ts @@ -397,6 +397,19 @@ 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. + * + * 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), + lt(knowledgeBase.deletedAt, retentionDate) + ), onBatch: (rows) => hardDeleteKnowledgeBaseDocuments( rows.map(({ id }) => id), @@ -417,12 +430,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/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/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/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/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.test.ts b/apps/sim/hooks/queries/folders.test.ts index 2bb75d22466..7a5526e9d02 100644 --- a/apps/sim/hooks/queries/folders.test.ts +++ b/apps/sim/hooks/queries/folders.test.ts @@ -71,8 +71,6 @@ describe('folder optimistic top insertion ordering', () => { userId: 'user-1', workspaceId: 'ws-1', parentId: 'parent-1', - color: '#808080', - isExpanded: false, sortOrder: 5, createdAt: new Date(), updatedAt: new Date(), @@ -83,8 +81,6 @@ describe('folder optimistic top insertion ordering', () => { userId: 'user-1', workspaceId: 'ws-1', parentId: 'parent-2', - color: '#808080', - isExpanded: false, sortOrder: -100, createdAt: new Date(), updatedAt: new Date(), diff --git a/apps/sim/hooks/queries/folders.ts b/apps/sim/hooks/queries/folders.ts index c82f65416cc..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,42 +13,25 @@ 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 { 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' 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', @@ -129,6 +111,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, 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 + } +} + /** * Creates optimistic mutation handlers for folder operations */ @@ -187,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, @@ -197,7 +219,7 @@ export function useCreateFolder() { userId: '', workspaceId: variables.workspaceId, parentId: variables.parentId || null, - resourceType: variables.resourceType ?? 'workflow', + resourceType, locked: false, sortOrder: variables.sortOrder ?? @@ -270,8 +292,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,12 +320,17 @@ 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) }, }) } +/** + * 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/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 f31a3e00154..118b81ba5e5 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,14 @@ import { } from '@/lib/api/contracts/knowledge' import type { ChunkingStrategy, StrategyOptions } from '@/lib/chunkers/types' import type { DocumentSortField, SortOrder } from '@/lib/knowledge/documents/types' +import { + KNOWLEDGE_BASE_LIST_STALE_TIME, + type KnowledgeQueryScope, + knowledgeKeys, +} from '@/hooks/queries/utils/knowledge-keys' const logger = createLogger('KnowledgeQueries') -type KnowledgeQueryScope = KnowledgeScope - export type { DocumentTagDefinitionData, DocumentTagFilter, @@ -61,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 @@ -71,30 +72,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', @@ -607,6 +584,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 +622,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 +644,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..3922ea555db 100644 --- a/apps/sim/hooks/queries/tables.ts +++ b/apps/sim/hooks/queries/tables.ts @@ -636,6 +636,46 @@ export function useUpdateTableLocks(workspaceId: string) { }) } +/** + * 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 }) + }, + }) +} + /** * Delete a table from a workspace. */ @@ -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/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/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 new file mode 100644 index 00000000000..12edbd76717 --- /dev/null +++ b/apps/sim/hooks/queries/utils/knowledge-keys.ts @@ -0,0 +1,40 @@ +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 + +/** 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, + 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 new file mode 100644 index 00000000000..20c129c64bc --- /dev/null +++ b/apps/sim/lib/api/contracts/folders.test.ts @@ -0,0 +1,35 @@ +/** + * @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', '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 ec2e6317a32..d7ab87430d0 100644 --- a/apps/sim/lib/api/contracts/folders.ts +++ b/apps/sim/lib/api/contracts/folders.ts @@ -6,13 +6,27 @@ 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 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 + * 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, pinned by `folders.test.ts`. */ export const servedFolderResourceTypeSchema = z - .enum(['workflow', 'knowledge_base', 'table']) + .enum(['workflow', 'knowledge_base', 'table'], { + error: 'resourceType must be one of workflow, 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/cleanup/batch-delete.ts b/apps/sim/lib/cleanup/batch-delete.ts index 6e51c6ceb77..fd4406f6d8e 100644 --- a/apps/sim/lib/cleanup/batch-delete.ts +++ b/apps/sim/lib/cleanup/batch-delete.ts @@ -81,6 +81,17 @@ 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. 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 /** Max batches per workspace chunk. */ maxBatches?: number @@ -112,6 +123,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 +173,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 @@ -226,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/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/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/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/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 6fd76dae2f0..4c6b3d6333e 100644 --- a/apps/sim/lib/folders/config.ts +++ b/apps/sim/lib/folders/config.ts @@ -283,9 +283,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 +320,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 @@ -380,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. `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 + * 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, @@ -418,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/lifecycle.test.ts b/apps/sim/lib/folders/lifecycle.test.ts new file mode 100644 index 00000000000..94074a09095 --- /dev/null +++ b/apps/sim/lib/folders/lifecycle.test.ts @@ -0,0 +1,688 @@ +/** + * @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('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()]) + + 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 b26d9f2a00e..7b02f7f4f1b 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 * 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/apps/sim/lib/folders/queries.ts b/apps/sim/lib/folders/queries.ts index 897aea5b956..77cc4392756 100644 --- a/apps/sim/lib/folders/queries.ts +++ b/apps/sim/lib/folders/queries.ts @@ -49,6 +49,64 @@ export async function wouldCreateFolderCycle( return false } +/** + * Loads an active folder, scoped to both its workspace and its `resourceType`. + * + * Every foldered resource needs this before writing its `folderId` column. The FK only + * proves the folder row exists — not that it belongs to this workspace or to this + * resource's tree — so without the check a caller could file a knowledge base under + * another tenant's folder, or under a table folder that the Knowledge page will never + * render, stranding the row invisibly. The DB trigger `folder_parent_resource_type_match` + * does not help here: it only guards folder parents. + * + * Returns `null` when the folder is missing, archived, in another workspace, or belongs to + * another resource's tree — a single "not a valid destination" answer. + */ +export async function findActiveFolder( + folderId: string, + workspaceId: string, + resourceType: FolderResourceType +): 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 +} + +/** + * Where a restored resource should land: its original folder when that folder is reachable, + * otherwise the workspace root. + * + * `restoringFolderIds` is what makes this safe inside a folder cascade. A `restoreChildren` + * hook runs BEFORE the folder rows are un-archived (see `restoreFolder` — that ordering is + * what keeps a partial failure retryable), so a naive "is my folder active?" check sees the + * folder still archived and dumps every child at the root. Passing the subtree being + * restored tells the check to treat those folders as already back. + * + * Without any set — a single-resource restore out of Recently Deleted — an archived folder + * still re-roots, because filing a row under a folder no page renders makes it unreachable. + */ +export async function resolveRestoredFolderId( + folderId: string | null | undefined, + workspaceId: string | null | undefined, + resourceType: FolderResourceType, + restoringFolderIds?: ReadonlySet +): 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/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..cbac1283b63 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, resolveRestoredFolderId } 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) @@ -728,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({ @@ -736,6 +802,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 +824,20 @@ 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 = await resolveRestoredFolderId( + kb.folderId, + kb.workspaceId, + 'knowledge_base', + options?.restoringFolderIds + ) + /** * 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 +872,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/resources/orchestration/restore-resource.ts b/apps/sim/lib/resources/orchestration/restore-resource.ts index de305bdd606..75baa13aaf4 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,33 @@ 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. + * + * `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. + */ +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', +} export interface PerformRestoreResourceParams { type: RestorableResourceType @@ -132,17 +160,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 22bdca95170..5d23ec56931 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' @@ -160,6 +161,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 +191,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 +221,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 +259,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 +311,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 +433,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 +621,74 @@ 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, + workspaceId: string, + folderId: string | null, + requestId: string, + actingUserId?: string +): Promise { + const updates: Partial = { + folderId, + 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( + and( + eq(userTableDefinitions.id, tableId), + eq(userTableDefinitions.workspaceId, workspaceId), + isNull(userTableDefinitions.archivedAt) + ) + ) + .returning({ + name: userTableDefinitions.name, + createdBy: userTableDefinitions.createdBy, + }) + + if (result.length === 0) { + throw new Error(`Table ${tableId} not found`) + } + + const { name, createdBy } = result[0] + const actorId = actingUserId ?? createdBy + if (actorId) { + recordAudit({ + workspaceId, + 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. @@ -825,7 +900,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') @@ -843,6 +922,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. @@ -875,7 +968,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/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 { diff --git a/apps/sim/lib/workflows/lifecycle.ts b/apps/sim/lib/workflows/lifecycle.ts index ee605fe0e8e..965ae9ca62b 100644 --- a/apps/sim/lib/workflows/lifecycle.ts +++ b/apps/sim/lib/workflows/lifecycle.ts @@ -236,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) @@ -249,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/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_reconcile.sql b/packages/db/migrations/0274_file_folder_cutover_reconcile.sql new file mode 100644 index 00000000000..8730b273bb8 --- /dev/null +++ b/packages/db/migrations/0274_file_folder_cutover_reconcile.sql @@ -0,0 +1,86 @@ +-- 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. 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. +-- +-- 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 +-- 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 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 $$; 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 f36d0c740c1..eea606ba98c 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_reconcile", + "breakpoints": true } ] } diff --git a/packages/db/schema.ts b/packages/db/schema.ts index 614157916f6..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(), }, @@ -1902,9 +1902,22 @@ 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 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', {