Skip to content

Commit 8a968e4

Browse files
committed
minor comments
1 parent 304a7bb commit 8a968e4

8 files changed

Lines changed: 523 additions & 338 deletions

File tree

apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/promote-workspace-modal/promote-workspace-modal.tsx

Lines changed: 225 additions & 175 deletions
Large diffs are not rendered by default.

apps/sim/lib/workspaces/fork/copy/copy-workflows.ts

Lines changed: 19 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { workflow, workflowBlocks, workflowFolder } from '@sim/db/schema'
22
import { createLogger } from '@sim/logger'
33
import { generateId } from '@sim/utils/id'
4-
import { and, eq, inArray, isNull, ne } from 'drizzle-orm'
4+
import { and, eq, inArray, isNull } from 'drizzle-orm'
55
import type { DbOrTx } from '@/lib/db/types'
66
import { remapConditionEdgeHandle } from '@/lib/workflows/condition-ids'
77
import {
@@ -222,39 +222,23 @@ export async function loadTargetDraftSubBlocks(
222222
return byWorkflow
223223
}
224224

225-
async function resolveTargetWorkflowName(
226-
tx: DbOrTx,
227-
workspaceId: string,
225+
/**
226+
* Pick a non-colliding name for a copied workflow against the preloaded registry, which
227+
* mirrors the workspace's (folder, name, not-archived, exclude-self) predicate from one
228+
* query instead of one per candidate. Mirrors {@link deduplicateWorkflowName}'s ` (n)`
229+
* numbering, but reads from memory so the copy loop issues no per-workflow name queries.
230+
*/
231+
function resolveTargetWorkflowName(
232+
registry: WorkflowNameRegistry,
228233
folderId: string | null,
229234
name: string,
230-
excludeWorkflowId: string | null,
231-
registry?: WorkflowNameRegistry
232-
): Promise<string> {
233-
const folderCondition = folderId ? eq(workflow.folderId, folderId) : isNull(workflow.folderId)
234-
235-
const nameTaken = async (candidate: string): Promise<boolean> => {
236-
// The registry mirrors the same (workspace, folder, name, not-archived, exclude-self)
237-
// predicate as the DB query below, from one preload instead of a query per call.
238-
if (registry) return registry.isTaken(folderId, candidate, excludeWorkflowId)
239-
const conditions = [
240-
eq(workflow.workspaceId, workspaceId),
241-
folderCondition,
242-
eq(workflow.name, candidate),
243-
isNull(workflow.archivedAt),
244-
]
245-
if (excludeWorkflowId) conditions.push(ne(workflow.id, excludeWorkflowId))
246-
const [row] = await tx
247-
.select({ id: workflow.id })
248-
.from(workflow)
249-
.where(and(...conditions))
250-
.limit(1)
251-
return Boolean(row)
252-
}
253-
254-
if (!(await nameTaken(name))) return name
235+
excludeWorkflowId: string | null
236+
): string {
237+
const taken = (candidate: string) => registry.isTaken(folderId, candidate, excludeWorkflowId)
238+
if (!taken(name)) return name
255239
for (let i = 2; i < 100; i++) {
256240
const candidate = `${name} (${i})`
257-
if (!(await nameTaken(candidate))) return candidate
241+
if (!taken(candidate)) return candidate
258242
}
259243
return `${name} (${generateId().slice(0, 6)})`
260244
}
@@ -314,7 +298,7 @@ export interface CopyWorkflowStateParams {
314298
* Preloaded name registry so name-collision resolution reads from memory instead of one
315299
* query per workflow inside the tx. Build once per copy loop via {@link loadWorkflowNameRegistry}.
316300
*/
317-
nameRegistry?: WorkflowNameRegistry
301+
nameRegistry: WorkflowNameRegistry
318302
requestId?: string
319303
}
320304

@@ -481,17 +465,15 @@ export async function copyWorkflowStateIntoTarget(
481465
}
482466
}
483467

484-
const resolvedName = await resolveTargetWorkflowName(
485-
tx,
486-
targetWorkspaceId,
468+
const resolvedName = resolveTargetWorkflowName(
469+
nameRegistry,
487470
targetFolderId,
488471
sourceMeta.name,
489-
mode === 'replace' ? targetWorkflowId : null,
490-
nameRegistry
472+
mode === 'replace' ? targetWorkflowId : null
491473
)
492474
// Claim the resolved name so the next workflow in this copy loop sees it taken. The DB
493475
// write below uses the same (folderId, name), so the registry stays consistent with it.
494-
nameRegistry?.claim(targetFolderId, resolvedName, targetWorkflowId)
476+
nameRegistry.claim(targetFolderId, resolvedName, targetWorkflowId)
495477

496478
if (mode === 'create') {
497479
await tx.insert(workflow).values({

apps/sim/lib/workspaces/fork/copy/deploy-bridge.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -138,10 +138,19 @@ export async function loadSourceDeployedStates(sourceWorkspaceId: string): Promi
138138
400
139139
)
140140
}
141+
// Read states in bounded-concurrency batches instead of one serial await per workflow:
142+
// serial cost is O(workflows) round trips (this also runs on the diff preview, refetched
143+
// while the sync modal is open). The cap keeps concurrent global-pool checkouts well
144+
// under the pool max even at the workflow ceiling, and this runs BEFORE any transaction.
141145
const sourceStates = new Map<string, WorkflowState>()
142-
for (const wf of deployedWorkflows) {
143-
const state = await readDeployedState(wf.id, sourceWorkspaceId)
144-
if (state) sourceStates.set(wf.id, state)
146+
const READ_CONCURRENCY = 5
147+
for (let i = 0; i < deployedWorkflows.length; i += READ_CONCURRENCY) {
148+
const batch = deployedWorkflows.slice(i, i + READ_CONCURRENCY)
149+
const states = await Promise.all(batch.map((wf) => readDeployedState(wf.id, sourceWorkspaceId)))
150+
batch.forEach((wf, index) => {
151+
const state = states[index]
152+
if (state) sourceStates.set(wf.id, state)
153+
})
145154
}
146155
return { deployedWorkflows, sourceStates }
147156
}

apps/sim/lib/workspaces/fork/create-fork.ts

Lines changed: 46 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -312,36 +312,49 @@ export async function createFork(params: CreateForkParams): Promise<CreateForkRe
312312
// content to copy in the background the row stays `processing` until the runner
313313
// finishes it (merging in copied/failed); otherwise the fork is already complete.
314314
const forkedName = result.workspace.name
315-
const statusId = await startBackgroundWork(db, {
316-
workspaceId: source.id,
317-
kind: 'fork_content_copy',
318-
// Append-only: each fork is a distinct entry in the source workspace's fork history.
319-
supersede: false,
320-
message: hasContent ? `Copying resources to "${forkedName}"` : `Forked into "${forkedName}"`,
321-
metadata: {
315+
// The fork already committed; failing to record the tracking row must not turn it into
316+
// a 500. Log and continue without a status row - the background content copy below still
317+
// runs (its runner no-ops the status update when statusId is absent).
318+
let statusId: string | undefined
319+
try {
320+
statusId = await startBackgroundWork(db, {
321+
workspaceId: source.id,
322+
kind: 'fork_content_copy',
323+
// Append-only: each fork is a distinct entry in the source workspace's fork history.
324+
supersede: false,
325+
message: hasContent ? `Copying resources to "${forkedName}"` : `Forked into "${forkedName}"`,
326+
metadata: {
327+
childWorkspaceId: result.workspace.id,
328+
childWorkspaceName: forkedName,
329+
actorName: params.actorName,
330+
workflowsCopied: result.workflowsCopied,
331+
tables: contentPlan.tables.length,
332+
knowledgeBases: contentPlan.knowledgeBases.length,
333+
files: blobTasks.length,
334+
workflowNames: forkedWorkflowNames,
335+
tableNames: forkedResourceNames.tables,
336+
knowledgeBaseNames: forkedResourceNames.knowledgeBases,
337+
fileNames: blobTasks.map((task) => task.fileName),
338+
customToolNames: forkedResourceNames.customTools,
339+
skillNames: forkedResourceNames.skills,
340+
mcpServerNames: forkedResourceNames.mcpServers,
341+
},
342+
})
343+
} catch (error) {
344+
logger.error(`[${requestId}] Failed to record fork background-work status`, {
322345
childWorkspaceId: result.workspace.id,
323-
childWorkspaceName: forkedName,
324-
actorName: params.actorName,
325-
workflowsCopied: result.workflowsCopied,
326-
tables: contentPlan.tables.length,
327-
knowledgeBases: contentPlan.knowledgeBases.length,
328-
files: blobTasks.length,
329-
workflowNames: forkedWorkflowNames,
330-
tableNames: forkedResourceNames.tables,
331-
knowledgeBaseNames: forkedResourceNames.knowledgeBases,
332-
fileNames: blobTasks.map((task) => task.fileName),
333-
customToolNames: forkedResourceNames.customTools,
334-
skillNames: forkedResourceNames.skills,
335-
mcpServerNames: forkedResourceNames.mcpServers,
336-
},
337-
})
346+
error: getErrorMessage(error),
347+
})
348+
}
338349

339350
if (!hasContent) {
340-
await finishBackgroundWork(db, statusId, {
341-
status: 'completed',
342-
message: `Forked into "${forkedName}"`,
343-
metadata: { copied: 0, failed: 0 },
344-
}).catch(() => {})
351+
if (statusId) {
352+
await finishBackgroundWork(db, statusId, {
353+
status: 'completed',
354+
message: `Forked into "${forkedName}"`,
355+
metadata: { copied: 0, failed: 0 },
356+
}).catch(() => {})
357+
}
345358
return result
346359
}
347360

@@ -366,10 +379,12 @@ export async function createFork(params: CreateForkParams): Promise<CreateForkRe
366379
childWorkspaceId: result.workspace.id,
367380
error: getErrorMessage(error),
368381
})
369-
await finishBackgroundWork(db, statusId, {
370-
status: 'failed',
371-
error: getErrorMessage(error, 'Could not start the background copy'),
372-
}).catch(() => {})
382+
if (statusId) {
383+
await finishBackgroundWork(db, statusId, {
384+
status: 'failed',
385+
error: getErrorMessage(error, 'Could not start the background copy'),
386+
}).catch(() => {})
387+
}
373388
}
374389

375390
return result

apps/sim/lib/workspaces/fork/mapping/mapping-service.test.ts

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,10 @@ vi.mock('@/lib/workspaces/fork/mapping/resources', () => ({
2020
}))
2121

2222
import { ForkError } from '@/lib/workspaces/fork/lineage/authz'
23-
import { validateForkMappingTargets } from '@/lib/workspaces/fork/mapping/mapping-service'
23+
import {
24+
findDuplicateTargetEntry,
25+
validateForkMappingTargets,
26+
} from '@/lib/workspaces/fork/mapping/mapping-service'
2427

2528
type ExistingByKind = Partial<Record<ForkRemapKind, Set<string>>>
2629

@@ -130,3 +133,50 @@ describe('validateForkMappingTargets', () => {
130133
).rejects.toBeInstanceOf(ForkError)
131134
})
132135
})
136+
137+
describe('findDuplicateTargetEntry', () => {
138+
it('returns null when every target is used by at most one source', () => {
139+
expect(
140+
findDuplicateTargetEntry([
141+
{ resourceType: 'oauth_credential', sourceId: 'c1', targetId: 't1' },
142+
{ resourceType: 'oauth_credential', sourceId: 'c2', targetId: 't2' },
143+
])
144+
).toBeNull()
145+
})
146+
147+
it('flags two distinct sources mapped to the same target', () => {
148+
expect(
149+
findDuplicateTargetEntry([
150+
{ resourceType: 'oauth_credential', sourceId: 'c1', targetId: 'shared' },
151+
{ resourceType: 'oauth_credential', sourceId: 'c2', targetId: 'shared' },
152+
])
153+
).toEqual({ resourceType: 'oauth_credential', targetId: 'shared' })
154+
})
155+
156+
it('ignores cleared (null target) entries', () => {
157+
expect(
158+
findDuplicateTargetEntry([
159+
{ resourceType: 'oauth_credential', sourceId: 'c1', targetId: null },
160+
{ resourceType: 'oauth_credential', sourceId: 'c2', targetId: null },
161+
])
162+
).toBeNull()
163+
})
164+
165+
it('does not flag the same source+target repeated', () => {
166+
expect(
167+
findDuplicateTargetEntry([
168+
{ resourceType: 'table', sourceId: 'c1', targetId: 't1' },
169+
{ resourceType: 'table', sourceId: 'c1', targetId: 't1' },
170+
])
171+
).toBeNull()
172+
})
173+
174+
it('does not conflate the same target id across resource types', () => {
175+
expect(
176+
findDuplicateTargetEntry([
177+
{ resourceType: 'oauth_credential', sourceId: 'c1', targetId: 'same' },
178+
{ resourceType: 'table', sourceId: 'c2', targetId: 'same' },
179+
])
180+
).toBeNull()
181+
})
182+
})

apps/sim/lib/workspaces/fork/mapping/mapping-service.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,35 @@ export interface ApplyForkMappingEntry {
221221
targetId: string | null
222222
}
223223

224+
/**
225+
* The first target two distinct sources are mapped to (same resourceType + targetId,
226+
* different sourceId), or null when every target is used by at most one source. Cleared
227+
* entries (null target) are ignored. Used by the PUSH path only: a push row is unique on
228+
* the parent (target) side, so such a pair collides on that unique index and one mapping
229+
* would be silently dropped - the caller rejects it instead. Pull is the inverse (many
230+
* parent sources may share one child target, which the resolver handles), so pull does not
231+
* use this guard.
232+
*/
233+
export function findDuplicateTargetEntry(
234+
entries: ApplyForkMappingEntry[]
235+
): { resourceType: ForkResourceType; targetId: string } | null {
236+
const sourcesByTarget = new Map<string, Set<string>>()
237+
for (const entry of entries) {
238+
if (entry.targetId == null) continue
239+
// Null-byte separator so a targetId containing ':' (e.g. credentialSet:...) can't
240+
// be confused with a different (resourceType, targetId) pair.
241+
const key = `${entry.resourceType}\u0000${entry.targetId}`
242+
const sources = sourcesByTarget.get(key)
243+
if (!sources) {
244+
sourcesByTarget.set(key, new Set([entry.sourceId]))
245+
continue
246+
}
247+
sources.add(entry.sourceId)
248+
if (sources.size > 1) return { resourceType: entry.resourceType, targetId: entry.targetId }
249+
}
250+
return null
251+
}
252+
224253
/**
225254
* Persist mapping edits for a direction. Pull maps a parent source to a child
226255
* target; push maps a child source to a parent target (clearing a push mapping
@@ -248,6 +277,20 @@ export async function applyForkMappingEntries(
248277
)
249278
return entries.length
250279
}
280+
// Push rows are unique on the parent (target) side, so two distinct sources mapped to
281+
// the same target would collide on that index and one would be silently dropped (its
282+
// reference then resolves unmapped). Reject loudly - on push each parent target can back
283+
// only one source. (Pull is the inverse: many parent sources may share one child target,
284+
// which the resolver handles, so pull skips this guard. The modal also disables an
285+
// already-taken target on push so users never reach this error normally.)
286+
const collision = findDuplicateTargetEntry(entries)
287+
if (collision) {
288+
const kind = resourceTypeToForkKind(collision.resourceType) ?? collision.resourceType
289+
throw new ForkError(
290+
`Two sources are mapped to the same ${kind} target. Each target can be mapped from only one source.`,
291+
400
292+
)
293+
}
251294
// Push rows are keyed by the child (source) side, but the table's unique key is on
252295
// the parent side - so clear any existing row for each source first (one grouped
253296
// delete), otherwise changing a push target leaves the old (parent, source) row

apps/sim/lib/workspaces/fork/promote/promote-plan.ts

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { workflow } from '@sim/db/schema'
1+
import { workflow, workspace } from '@sim/db/schema'
22
import { generateId } from '@sim/utils/id'
33
import { and, eq, isNull } from 'drizzle-orm'
44
import type { DbOrTx } from '@/lib/db/types'
@@ -260,9 +260,17 @@ export async function computeForkPromotePlan(params: {
260260
const unmappedOptional = allUnmapped.filter((reference) => !reference.required)
261261

262262
const previousRun = await getPromoteRunForEdge(executor, edge.childWorkspaceId, targetWorkspaceId)
263-
const drift = Boolean(
264-
previousRun && targetWorkflows.some((w) => w.updatedAt > previousRun.createdAt)
265-
)
263+
let driftBaseline = previousRun?.createdAt ?? null
264+
if (driftBaseline == null) {
265+
const [childWorkspace] = await executor
266+
.select({ createdAt: workspace.createdAt })
267+
.from(workspace)
268+
.where(eq(workspace.id, edge.childWorkspaceId))
269+
.limit(1)
270+
driftBaseline = childWorkspace?.createdAt ?? null
271+
}
272+
const baseline = driftBaseline
273+
const drift = baseline != null && targetWorkflows.some((w) => w.updatedAt > baseline)
266274

267275
const willUpdate = items.filter((i) => i.mode === 'replace').length
268276
const willCreate = items.filter((i) => i.mode === 'create').length

0 commit comments

Comments
 (0)