From a67aa3d15712846f6ff87678ded0214aa7a1d226 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Mon, 23 Feb 2026 04:58:32 -0800 Subject: [PATCH 1/4] improvement(credentials): move client side automigration to server side --- .../credential-selector.tsx | 7 - .../components/tools/credential-selector.tsx | 3 - apps/sim/hooks/use-collaborative-workflow.ts | 9 +- apps/sim/lib/workflows/persistence/utils.ts | 136 ++++++++++++++++-- 4 files changed, 126 insertions(+), 29 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx index 76b7c4de2e..99d6779aa8 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx @@ -24,7 +24,6 @@ import { useCredentialSets } from '@/hooks/queries/credential-sets' import { useOAuthCredentials } from '@/hooks/queries/oauth-credentials' import { useOrganizations } from '@/hooks/queries/organization' import { useSubscriptionData } from '@/hooks/queries/subscription' -import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow' import { useCredentialRefreshTriggers } from '@/hooks/use-credential-refresh-triggers' import { getMissingRequiredScopes } from '@/hooks/use-oauth-scope-status' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' @@ -55,7 +54,6 @@ export function CredentialSelector({ const [isEditing, setIsEditing] = useState(false) const { activeWorkflowId } = useWorkflowRegistry() const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlock.id) - const { collaborativeSetSubblockValue } = useCollaborativeWorkflow() const requiredScopes = subBlock.requiredScopes || [] const label = subBlock.placeholder || 'Select credential' @@ -136,11 +134,6 @@ export function CredentialSelector({ if (!response.ok || cancelled) return const data = await response.json() if (!cancelled && data.credential?.displayName) { - if (data.credential.id !== selectedId) { - collaborativeSetSubblockValue(blockId, subBlock.id, data.credential.id, { - skipDependsOn: true, - }) - } setInaccessibleCredentialName(data.credential.displayName) } } catch { diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tools/credential-selector.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tools/credential-selector.tsx index ad7f41c1cf..7c2c77a840 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tools/credential-selector.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tools/credential-selector.tsx @@ -114,9 +114,6 @@ export function ToolCredentialSelector({ if (!response.ok || cancelled) return const data = await response.json() if (!cancelled && data.credential?.displayName) { - if (data.credential.id !== selectedId) { - onChangeRef.current(data.credential.id) - } setInaccessibleCredentialName(data.credential.displayName) } } catch { diff --git a/apps/sim/hooks/use-collaborative-workflow.ts b/apps/sim/hooks/use-collaborative-workflow.ts index 8a6c8d09b1..83fdd5ed17 100644 --- a/apps/sim/hooks/use-collaborative-workflow.ts +++ b/apps/sim/hooks/use-collaborative-workflow.ts @@ -1232,12 +1232,7 @@ export function useCollaborativeWorkflow() { ) const collaborativeSetSubblockValue = useCallback( - ( - blockId: string, - subblockId: string, - value: any, - options?: { _visited?: Set; skipDependsOn?: boolean } - ) => { + (blockId: string, subblockId: string, value: any, options?: { _visited?: Set }) => { if (isApplyingRemoteChange.current) return if (isBaselineDiffView) { @@ -1263,8 +1258,6 @@ export function useCollaborativeWorkflow() { }) } - if (options?.skipDependsOn) return - // Handle dependent subblock clearing (recursive calls) try { const visited = options?._visited || new Set() diff --git a/apps/sim/lib/workflows/persistence/utils.ts b/apps/sim/lib/workflows/persistence/utils.ts index b747177e3e..8a6c276aa1 100644 --- a/apps/sim/lib/workflows/persistence/utils.ts +++ b/apps/sim/lib/workflows/persistence/utils.ts @@ -7,9 +7,10 @@ import { workflowEdges, workflowSubflows, } from '@sim/db' +import { credential } from '@sim/db/schema' import { createLogger } from '@sim/logger' import type { InferInsertModel, InferSelectModel } from 'drizzle-orm' -import { and, desc, eq, sql } from 'drizzle-orm' +import { and, desc, eq, inArray, sql } from 'drizzle-orm' import type { Edge } from 'reactflow' import { v4 as uuidv4 } from 'uuid' import type { DbOrTx } from '@/lib/db/types' @@ -99,8 +100,10 @@ export async function loadDeployedWorkflowState(workflowId: string): Promise } + const { blocks: migratedBlocks } = await migrateCredentialIds(state.blocks || {}) + return { - blocks: state.blocks || {}, + blocks: migratedBlocks, edges: state.edges || [], loops: state.loops || {}, parallels: state.parallels || {}, @@ -185,6 +188,97 @@ export function migrateAgentBlocksToMessagesFormat( ) } +const CREDENTIAL_SUBBLOCK_IDS = new Set(['credential', 'triggerCredentials']) + +/** + * Migrates legacy `account.id` values to `credential.id` in OAuth subblocks. + * Collects all potential legacy IDs in a single batch query for efficiency. + * Also migrates `tool.params.credential` in agent block tool arrays. + */ +async function migrateCredentialIds( + blocks: Record +): Promise<{ blocks: Record; migrated: boolean }> { + const potentialLegacyIds = new Set() + + for (const block of Object.values(blocks)) { + for (const [subBlockId, subBlock] of Object.entries(block.subBlocks || {})) { + const value = (subBlock as { value?: unknown }).value + if ( + CREDENTIAL_SUBBLOCK_IDS.has(subBlockId) && + typeof value === 'string' && + value && + !value.startsWith('cred_') + ) { + potentialLegacyIds.add(value) + } + + if (subBlockId === 'tools' && Array.isArray(value)) { + for (const tool of value) { + const credParam = tool?.params?.credential + if (typeof credParam === 'string' && credParam && !credParam.startsWith('cred_')) { + potentialLegacyIds.add(credParam) + } + } + } + } + } + + if (potentialLegacyIds.size === 0) { + return { blocks, migrated: false } + } + + const rows = await db + .select({ id: credential.id, accountId: credential.accountId }) + .from(credential) + .where(inArray(credential.accountId, [...potentialLegacyIds])) + + if (rows.length === 0) { + return { blocks, migrated: false } + } + + const accountToCredential = new Map(rows.map((r) => [r.accountId!, r.id])) + + const migratedBlocks = Object.fromEntries( + Object.entries(blocks).map(([blockId, block]) => { + let blockChanged = false + const newSubBlocks = { ...block.subBlocks } + + for (const [subBlockId, subBlock] of Object.entries(newSubBlocks)) { + if (CREDENTIAL_SUBBLOCK_IDS.has(subBlockId) && typeof subBlock.value === 'string') { + const newId = accountToCredential.get(subBlock.value) + if (newId) { + newSubBlocks[subBlockId] = { ...subBlock, value: newId } + blockChanged = true + } + } + + if (subBlockId === 'tools' && Array.isArray(subBlock.value)) { + let toolsChanged = false + const newTools = (subBlock.value as any[]).map((tool: any) => { + const credParam = tool?.params?.credential + if (typeof credParam === 'string') { + const newId = accountToCredential.get(credParam) + if (newId) { + toolsChanged = true + return { ...tool, params: { ...tool.params, credential: newId } } + } + } + return tool + }) + if (toolsChanged) { + newSubBlocks[subBlockId] = { ...subBlock, value: newTools as any } + blockChanged = true + } + } + } + + return [blockId, blockChanged ? { ...block, subBlocks: newSubBlocks } : block] + }) + ) + + return { blocks: migratedBlocks, migrated: true } +} + /** * Load workflow state from normalized tables * Returns null if no data found (fallback to JSON blob) @@ -236,9 +330,31 @@ export async function loadWorkflowFromNormalizedTables( const { blocks: sanitizedBlocks } = sanitizeAgentToolsInBlocks(blocksMap) // Migrate old agent block format (systemPrompt/userPrompt) to new messages array format - // This ensures backward compatibility for workflows created before the messages-input refactor const migratedBlocks = migrateAgentBlocksToMessagesFormat(sanitizedBlocks) + // Migrate legacy account.id → credential.id in OAuth subblocks + const { blocks: credMigratedBlocks, migrated: credentialsMigrated } = + await migrateCredentialIds(migratedBlocks) + + if (credentialsMigrated) { + Promise.resolve().then(async () => { + try { + for (const [blockId, block] of Object.entries(credMigratedBlocks)) { + if (block.subBlocks !== migratedBlocks[blockId]?.subBlocks) { + await db + .update(workflowBlocks) + .set({ subBlocks: block.subBlocks, updatedAt: new Date() }) + .where( + and(eq(workflowBlocks.id, blockId), eq(workflowBlocks.workflowId, workflowId)) + ) + } + } + } catch (err) { + logger.warn('Failed to persist credential ID migration', { workflowId, error: err }) + } + }) + } + // Convert edges to the expected format const edgesArray: Edge[] = edges.map((edge) => ({ id: edge.id, @@ -275,15 +391,13 @@ export async function loadWorkflowFromNormalizedTables( forEachItems: (config as Loop).forEachItems ?? '', whileCondition: (config as Loop).whileCondition ?? '', doWhileCondition: (config as Loop).doWhileCondition ?? '', - enabled: migratedBlocks[subflow.id]?.enabled ?? true, + enabled: credMigratedBlocks[subflow.id]?.enabled ?? true, } loops[subflow.id] = loop - // Sync block.data with loop config to ensure all fields are present - // This allows switching between loop types without losing data - if (migratedBlocks[subflow.id]) { - const block = migratedBlocks[subflow.id] - migratedBlocks[subflow.id] = { + if (credMigratedBlocks[subflow.id]) { + const block = credMigratedBlocks[subflow.id] + credMigratedBlocks[subflow.id] = { ...block, data: { ...block.data, @@ -304,7 +418,7 @@ export async function loadWorkflowFromNormalizedTables( (config as Parallel).parallelType === 'collection' ? (config as Parallel).parallelType : 'count', - enabled: migratedBlocks[subflow.id]?.enabled ?? true, + enabled: credMigratedBlocks[subflow.id]?.enabled ?? true, } parallels[subflow.id] = parallel } else { @@ -313,7 +427,7 @@ export async function loadWorkflowFromNormalizedTables( }) return { - blocks: migratedBlocks, + blocks: credMigratedBlocks, edges: edgesArray, loops, parallels, From ff99fbe792af6651e2ef4d73c77452cb16740ffb Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Mon, 23 Feb 2026 05:36:55 -0800 Subject: [PATCH 2/4] fix migration func --- .../app/api/workflows/[id]/deployed/route.ts | 31 +++++++++---------- apps/sim/lib/workflows/persistence/utils.ts | 31 +++++++++++++++---- 2 files changed, 40 insertions(+), 22 deletions(-) diff --git a/apps/sim/app/api/workflows/[id]/deployed/route.ts b/apps/sim/app/api/workflows/[id]/deployed/route.ts index e939fc0f09..2e335e3de4 100644 --- a/apps/sim/app/api/workflows/[id]/deployed/route.ts +++ b/apps/sim/app/api/workflows/[id]/deployed/route.ts @@ -1,9 +1,8 @@ -import { db, workflowDeploymentVersion } from '@sim/db' import { createLogger } from '@sim/logger' -import { and, desc, eq } from 'drizzle-orm' import type { NextRequest, NextResponse } from 'next/server' import { verifyInternalToken } from '@/lib/auth/internal' import { generateRequestId } from '@/lib/core/utils/request' +import { loadDeployedWorkflowState } from '@/lib/workflows/persistence/utils' import { validateWorkflowPermissions } from '@/lib/workflows/utils' import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils' @@ -43,21 +42,21 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ logger.debug(`[${requestId}] Internal API call for deployed workflow: ${id}`) } - const [active] = await db - .select({ state: workflowDeploymentVersion.state }) - .from(workflowDeploymentVersion) - .where( - and( - eq(workflowDeploymentVersion.workflowId, id), - eq(workflowDeploymentVersion.isActive, true) - ) - ) - .orderBy(desc(workflowDeploymentVersion.createdAt)) - .limit(1) + let deployedState = null + try { + const data = await loadDeployedWorkflowState(id) + deployedState = { + blocks: data.blocks, + edges: data.edges, + loops: data.loops, + parallels: data.parallels, + variables: data.variables, + } + } catch { + deployedState = null + } - const response = createSuccessResponse({ - deployedState: active?.state || null, - }) + const response = createSuccessResponse({ deployedState }) return addNoCacheHeaders(response) } catch (error: any) { logger.error(`[${requestId}] Error fetching deployed state: ${id}`, error) diff --git a/apps/sim/lib/workflows/persistence/utils.ts b/apps/sim/lib/workflows/persistence/utils.ts index 8a6c276aa1..bd921c4aa9 100644 --- a/apps/sim/lib/workflows/persistence/utils.ts +++ b/apps/sim/lib/workflows/persistence/utils.ts @@ -100,7 +100,16 @@ export async function loadDeployedWorkflowState(workflowId: string): Promise } - const { blocks: migratedBlocks } = await migrateCredentialIds(state.blocks || {}) + const [wfRow] = await db + .select({ workspaceId: workflow.workspaceId }) + .from(workflow) + .where(eq(workflow.id, workflowId)) + .limit(1) + + const { blocks: migratedBlocks } = await migrateCredentialIds( + state.blocks || {}, + wfRow?.workspaceId ?? undefined + ) return { blocks: migratedBlocks, @@ -196,7 +205,8 @@ const CREDENTIAL_SUBBLOCK_IDS = new Set(['credential', 'triggerCredentials']) * Also migrates `tool.params.credential` in agent block tool arrays. */ async function migrateCredentialIds( - blocks: Record + blocks: Record, + workspaceId?: string ): Promise<{ blocks: Record; migrated: boolean }> { const potentialLegacyIds = new Set() @@ -227,10 +237,15 @@ async function migrateCredentialIds( return { blocks, migrated: false } } + const conditions = [inArray(credential.accountId, [...potentialLegacyIds])] + if (workspaceId) { + conditions.push(eq(credential.workspaceId, workspaceId)) + } + const rows = await db .select({ id: credential.id, accountId: credential.accountId }) .from(credential) - .where(inArray(credential.accountId, [...potentialLegacyIds])) + .where(and(...conditions)) if (rows.length === 0) { return { blocks, migrated: false } @@ -287,11 +302,15 @@ export async function loadWorkflowFromNormalizedTables( workflowId: string ): Promise { try { - // Load all components in parallel - const [blocks, edges, subflows] = await Promise.all([ + const [blocks, edges, subflows, [workflowRow]] = await Promise.all([ db.select().from(workflowBlocks).where(eq(workflowBlocks.workflowId, workflowId)), db.select().from(workflowEdges).where(eq(workflowEdges.workflowId, workflowId)), db.select().from(workflowSubflows).where(eq(workflowSubflows.workflowId, workflowId)), + db + .select({ workspaceId: workflow.workspaceId }) + .from(workflow) + .where(eq(workflow.id, workflowId)) + .limit(1), ]) // If no blocks found, assume this workflow hasn't been migrated yet @@ -334,7 +353,7 @@ export async function loadWorkflowFromNormalizedTables( // Migrate legacy account.id → credential.id in OAuth subblocks const { blocks: credMigratedBlocks, migrated: credentialsMigrated } = - await migrateCredentialIds(migratedBlocks) + await migrateCredentialIds(migratedBlocks, workflowRow?.workspaceId ?? undefined) if (credentialsMigrated) { Promise.resolve().then(async () => { From 839d1055da94dc50abe344d252e045901dfc14c7 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Mon, 23 Feb 2026 05:53:02 -0800 Subject: [PATCH 3/4] fix tests --- .../sim/lib/workflows/persistence/utils.test.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/apps/sim/lib/workflows/persistence/utils.test.ts b/apps/sim/lib/workflows/persistence/utils.test.ts index d95cd633a9..ef4c00fab8 100644 --- a/apps/sim/lib/workflows/persistence/utils.test.ts +++ b/apps/sim/lib/workflows/persistence/utils.test.ts @@ -329,6 +329,9 @@ describe('Database Helpers', () => { if (callCount === 3) { return Promise.resolve(mockSubflowsFromDb) } + if (callCount === 4) { + return { limit: vi.fn().mockResolvedValue([{ workspaceId: 'test-workspace-id' }]) } + } return Promise.resolve([]) }), }), @@ -431,6 +434,8 @@ describe('Database Helpers', () => { if (callCount === 1) return Promise.resolve(mockBlocksFromDb) if (callCount === 2) return Promise.resolve(mockEdgesFromDb) if (callCount === 3) return Promise.resolve(subflowsWithUnknownType) + if (callCount === 4) + return { limit: vi.fn().mockResolvedValue([{ workspaceId: 'test-workspace-id' }]) } return Promise.resolve([]) }), }), @@ -469,6 +474,8 @@ describe('Database Helpers', () => { if (callCount === 1) return Promise.resolve(malformedBlocks) if (callCount === 2) return Promise.resolve([]) if (callCount === 3) return Promise.resolve([]) + if (callCount === 4) + return { limit: vi.fn().mockResolvedValue([{ workspaceId: 'test-workspace-id' }]) } return Promise.resolve([]) }), }), @@ -851,6 +858,8 @@ describe('Database Helpers', () => { if (callCount === 1) return Promise.resolve(testBlocks) if (callCount === 2) return Promise.resolve([]) if (callCount === 3) return Promise.resolve([]) + if (callCount === 4) + return { limit: vi.fn().mockResolvedValue([{ workspaceId: 'test-workspace-id' }]) } return Promise.resolve([]) }), }), @@ -888,6 +897,8 @@ describe('Database Helpers', () => { where: vi.fn().mockImplementation(() => { callCount++ if (callCount === 1) return Promise.resolve(blocksWithDefaultValues) + if (callCount === 4) + return { limit: vi.fn().mockResolvedValue([{ workspaceId: 'test-workspace-id' }]) } return Promise.resolve([]) }), }), @@ -957,6 +968,8 @@ describe('Database Helpers', () => { if (callCount === 1) return Promise.resolve([originalBlock, duplicatedBlock]) if (callCount === 2) return Promise.resolve([]) if (callCount === 3) return Promise.resolve([]) + if (callCount === 4) + return { limit: vi.fn().mockResolvedValue([{ workspaceId: 'test-workspace-id' }]) } return Promise.resolve([]) }), }), @@ -1053,6 +1066,8 @@ describe('Database Helpers', () => { where: vi.fn().mockImplementation(() => { callCount++ if (callCount === 1) return Promise.resolve([basicBlock, advancedBlock]) + if (callCount === 4) + return { limit: vi.fn().mockResolvedValue([{ workspaceId: 'test-workspace-id' }]) } return Promise.resolve([]) }), }), @@ -1137,6 +1152,8 @@ describe('Database Helpers', () => { }, ]) } + if (callCount === 4) + return { limit: vi.fn().mockResolvedValue([{ workspaceId: 'test-workspace-id' }]) } return Promise.resolve([]) }), }), From 9e92f354d2961193e8a84a4f3d4d0a60cf929db3 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Mon, 23 Feb 2026 05:58:26 -0800 Subject: [PATCH 4/4] address bugbot --- apps/sim/lib/workflows/persistence/utils.ts | 33 ++++++++++++--------- 1 file changed, 19 insertions(+), 14 deletions(-) diff --git a/apps/sim/lib/workflows/persistence/utils.ts b/apps/sim/lib/workflows/persistence/utils.ts index bd921c4aa9..3e2a16c5fa 100644 --- a/apps/sim/lib/workflows/persistence/utils.ts +++ b/apps/sim/lib/workflows/persistence/utils.ts @@ -106,10 +106,10 @@ export async function loadDeployedWorkflowState(workflowId: string): Promise, - workspaceId?: string + workspaceId: string ): Promise<{ blocks: Record; migrated: boolean }> { const potentialLegacyIds = new Set() @@ -237,15 +237,15 @@ async function migrateCredentialIds( return { blocks, migrated: false } } - const conditions = [inArray(credential.accountId, [...potentialLegacyIds])] - if (workspaceId) { - conditions.push(eq(credential.workspaceId, workspaceId)) - } - const rows = await db .select({ id: credential.id, accountId: credential.accountId }) .from(credential) - .where(and(...conditions)) + .where( + and( + inArray(credential.accountId, [...potentialLegacyIds]), + eq(credential.workspaceId, workspaceId) + ) + ) if (rows.length === 0) { return { blocks, migrated: false } @@ -291,7 +291,11 @@ async function migrateCredentialIds( }) ) - return { blocks: migratedBlocks, migrated: true } + const anyBlockChanged = Object.keys(migratedBlocks).some( + (id) => migratedBlocks[id] !== blocks[id] + ) + + return { blocks: migratedBlocks, migrated: anyBlockChanged } } /** @@ -352,8 +356,9 @@ export async function loadWorkflowFromNormalizedTables( const migratedBlocks = migrateAgentBlocksToMessagesFormat(sanitizedBlocks) // Migrate legacy account.id → credential.id in OAuth subblocks - const { blocks: credMigratedBlocks, migrated: credentialsMigrated } = - await migrateCredentialIds(migratedBlocks, workflowRow?.workspaceId ?? undefined) + const { blocks: credMigratedBlocks, migrated: credentialsMigrated } = workflowRow?.workspaceId + ? await migrateCredentialIds(migratedBlocks, workflowRow.workspaceId) + : { blocks: migratedBlocks, migrated: false } if (credentialsMigrated) { Promise.resolve().then(async () => {