mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
improvement(migration): move credential selector automigration logic to server side (#3310)
* improvement(credentials): move client side automigration to server side * fix migration func * fix tests * address bugbot
This commit is contained in:
@@ -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)
|
||||
|
||||
-7
@@ -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<string | null>(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 {
|
||||
|
||||
-3
@@ -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 {
|
||||
|
||||
@@ -1232,12 +1232,7 @@ export function useCollaborativeWorkflow() {
|
||||
)
|
||||
|
||||
const collaborativeSetSubblockValue = useCallback(
|
||||
(
|
||||
blockId: string,
|
||||
subblockId: string,
|
||||
value: any,
|
||||
options?: { _visited?: Set<string>; skipDependsOn?: boolean }
|
||||
) => {
|
||||
(blockId: string, subblockId: string, value: any, options?: { _visited?: Set<string> }) => {
|
||||
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<string>()
|
||||
|
||||
@@ -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([])
|
||||
}),
|
||||
}),
|
||||
|
||||
@@ -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,19 @@ export async function loadDeployedWorkflowState(workflowId: string): Promise<Dep
|
||||
|
||||
const state = active.state as WorkflowState & { variables?: Record<string, unknown> }
|
||||
|
||||
const [wfRow] = await db
|
||||
.select({ workspaceId: workflow.workspaceId })
|
||||
.from(workflow)
|
||||
.where(eq(workflow.id, workflowId))
|
||||
.limit(1)
|
||||
|
||||
const resolvedBlocks = state.blocks || {}
|
||||
const { blocks: migratedBlocks } = wfRow?.workspaceId
|
||||
? await migrateCredentialIds(resolvedBlocks, wfRow.workspaceId)
|
||||
: { blocks: resolvedBlocks }
|
||||
|
||||
return {
|
||||
blocks: state.blocks || {},
|
||||
blocks: migratedBlocks,
|
||||
edges: state.edges || [],
|
||||
loops: state.loops || {},
|
||||
parallels: state.parallels || {},
|
||||
@@ -185,6 +197,107 @@ 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<string, BlockState>,
|
||||
workspaceId: string
|
||||
): Promise<{ blocks: Record<string, BlockState>; migrated: boolean }> {
|
||||
const potentialLegacyIds = new Set<string>()
|
||||
|
||||
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(
|
||||
and(
|
||||
inArray(credential.accountId, [...potentialLegacyIds]),
|
||||
eq(credential.workspaceId, workspaceId)
|
||||
)
|
||||
)
|
||||
|
||||
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]
|
||||
})
|
||||
)
|
||||
|
||||
const anyBlockChanged = Object.keys(migratedBlocks).some(
|
||||
(id) => migratedBlocks[id] !== blocks[id]
|
||||
)
|
||||
|
||||
return { blocks: migratedBlocks, migrated: anyBlockChanged }
|
||||
}
|
||||
|
||||
/**
|
||||
* Load workflow state from normalized tables
|
||||
* Returns null if no data found (fallback to JSON blob)
|
||||
@@ -193,11 +306,15 @@ export async function loadWorkflowFromNormalizedTables(
|
||||
workflowId: string
|
||||
): Promise<NormalizedWorkflowData | null> {
|
||||
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
|
||||
@@ -236,9 +353,32 @@ 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 } = workflowRow?.workspaceId
|
||||
? await migrateCredentialIds(migratedBlocks, workflowRow.workspaceId)
|
||||
: { blocks: migratedBlocks, migrated: false }
|
||||
|
||||
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 +415,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 +442,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 +451,7 @@ export async function loadWorkflowFromNormalizedTables(
|
||||
})
|
||||
|
||||
return {
|
||||
blocks: migratedBlocks,
|
||||
blocks: credMigratedBlocks,
|
||||
edges: edgesArray,
|
||||
loops,
|
||||
parallels,
|
||||
|
||||
Reference in New Issue
Block a user