Skip to content

Commit 1ccbe7f

Browse files
committed
perf improvements
1 parent 032342d commit 1ccbe7f

19 files changed

Lines changed: 407 additions & 198 deletions

File tree

apps/sim/app/api/workspaces/[id]/fork/lineage/route.ts

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { parseRequest } from '@/lib/api/server'
88
import { getSession } from '@/lib/auth'
99
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
1010
import { assertWorkspaceAdminAccess } from '@/lib/workspaces/fork/lineage/authz'
11-
import { getForkLineage } from '@/lib/workspaces/fork/lineage/lineage'
11+
import { getForkParent } from '@/lib/workspaces/fork/lineage/lineage'
1212
import { getUndoableRunForTarget } from '@/lib/workspaces/fork/promote/promote-run-store'
1313

1414
export const GET = withRouteHandler(
@@ -24,8 +24,8 @@ export const GET = withRouteHandler(
2424

2525
await assertWorkspaceAdminAccess(workspaceId, session.user.id)
2626

27-
const [{ parent, children }, run] = await Promise.all([
28-
getForkLineage(workspaceId),
27+
const [parent, run] = await Promise.all([
28+
getForkParent(workspaceId),
2929
getUndoableRunForTarget(db, workspaceId),
3030
])
3131

@@ -50,8 +50,6 @@ export const GET = withRouteHandler(
5050
return NextResponse.json({
5151
workspaceId,
5252
parent,
53-
children,
54-
hasUndoableRun: Boolean(run),
5553
undoableRun,
5654
})
5755
}

apps/sim/app/api/workspaces/[id]/fork/mapping/route.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { parseRequest } from '@/lib/api/server'
88
import { getSession } from '@/lib/auth'
99
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
1010
import { assertCanPromote } from '@/lib/workspaces/fork/lineage/authz'
11+
import { acquireForkEdgeLock, setForkLockTimeout } from '@/lib/workspaces/fork/lineage/lineage'
1112
import {
1213
applyForkMappingEntries,
1314
getForkMappingView,
@@ -60,9 +61,14 @@ export const PUT = withRouteHandler(
6061

6162
await validateForkMappingTargets(auth.sourceWorkspaceId, auth.targetWorkspaceId, entries)
6263

63-
const updated = await db.transaction((tx) =>
64-
applyForkMappingEntries(tx, auth.edge, session.user.id, direction, entries)
65-
)
64+
// Serialize concurrent mapping saves on this edge so a push (keyed child-side, deleted
65+
// then re-upserted parent-side) can't leave duplicate rows for the same source. Same
66+
// edge lock promote/rollback use, with a bounded wait.
67+
const updated = await db.transaction(async (tx) => {
68+
await setForkLockTimeout(tx)
69+
await acquireForkEdgeLock(tx, auth.edge.childWorkspaceId)
70+
return applyForkMappingEntries(tx, auth.edge, session.user.id, direction, entries)
71+
})
6672

6773
return NextResponse.json({ success: true as const, updated })
6874
}

apps/sim/hooks/queries/workspace-fork.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,11 @@ export function usePromoteFork() {
142142
mutationFn: (vars: { workspaceId: string; body: PromoteForkBody }) =>
143143
requestJson(promoteForkContract, { params: { id: vars.workspaceId }, body: vars.body }),
144144
onSettled: () => {
145-
queryClient.invalidateQueries({ queryKey: forkKeys.all })
145+
// A sync changes lineage (undoable run), mappings, and the diff - not the
146+
// workspace's copyable resource inventory, so leave `resources` cached.
147+
queryClient.invalidateQueries({ queryKey: forkKeys.lineages() })
148+
queryClient.invalidateQueries({ queryKey: forkKeys.mappings() })
149+
queryClient.invalidateQueries({ queryKey: forkKeys.diffs() })
146150
queryClient.invalidateQueries({ queryKey: backgroundWorkKeys.lists() })
147151
},
148152
})
@@ -154,7 +158,11 @@ export function useRollbackFork() {
154158
mutationFn: (vars: { workspaceId: string; body: RollbackForkBody }) =>
155159
requestJson(rollbackForkContract, { params: { id: vars.workspaceId }, body: vars.body }),
156160
onSettled: () => {
157-
queryClient.invalidateQueries({ queryKey: forkKeys.all })
161+
// Rollback changes lineage, mappings, and the diff - not the copyable resource
162+
// inventory, so leave `resources` cached (mirrors usePromoteFork).
163+
queryClient.invalidateQueries({ queryKey: forkKeys.lineages() })
164+
queryClient.invalidateQueries({ queryKey: forkKeys.mappings() })
165+
queryClient.invalidateQueries({ queryKey: forkKeys.diffs() })
158166
queryClient.invalidateQueries({ queryKey: backgroundWorkKeys.lists() })
159167
},
160168
})

apps/sim/lib/api/contracts/workspace-fork.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,8 +57,6 @@ export const getForkLineageContract = defineRouteContract({
5757
schema: z.object({
5858
workspaceId: z.string(),
5959
parent: forkLineageNodeSchema.nullable(),
60-
children: z.array(forkLineageNodeSchema),
61-
hasUndoableRun: z.boolean(),
6260
/** The most recent undoable promote into this workspace, for the rollback UI. */
6361
undoableRun: z
6462
.object({

apps/sim/lib/workspaces/fork/copy/content-copy-runner.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,9 @@ export interface ForkContentCopyPayload {
1515
contentPlan: ForkContentPlan
1616
blobTasks: BlobCopyTask[]
1717
/**
18-
* `background_work_status` row to finish when the copy ends, so the child
19-
* workspace's "copying in the background" banner clears (or shows a warning/error).
20-
* Started in the fork transaction so it's visible the moment the user opens the fork.
18+
* `background_work_status` row to finish when the copy ends, so the source workspace's
19+
* Manage Forks -> Activity entry resolves (completed / warning / error). Started right
20+
* after the fork commits so it's visible immediately.
2121
*/
2222
statusId?: string
2323
requestId?: string

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

Lines changed: 34 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -137,9 +137,10 @@ export async function copyForkResourceContainers(
137137
eq(customTools.workspaceId, sourceWorkspaceId)
138138
)
139139
)
140+
const inserts: (typeof customTools.$inferInsert)[] = []
140141
for (const row of rows) {
141142
const childId = generateId()
142-
await tx.insert(customTools).values({
143+
inserts.push({
143144
...row,
144145
id: childId,
145146
workspaceId: childWorkspaceId,
@@ -150,16 +151,18 @@ export async function copyForkResourceContainers(
150151
record('custom_tool', row.id, childId)
151152
names.customTools.push(row.title)
152153
}
154+
if (inserts.length > 0) await tx.insert(customTools).values(inserts)
153155
}
154156

155157
if (selection.skills.length > 0) {
156158
const rows = await tx
157159
.select()
158160
.from(skill)
159161
.where(and(inArray(skill.id, selection.skills), eq(skill.workspaceId, sourceWorkspaceId)))
162+
const inserts: (typeof skill.$inferInsert)[] = []
160163
for (const row of rows) {
161164
const childId = generateId()
162-
await tx.insert(skill).values({
165+
inserts.push({
163166
...row,
164167
id: childId,
165168
workspaceId: childWorkspaceId,
@@ -170,6 +173,7 @@ export async function copyForkResourceContainers(
170173
record('skill', row.id, childId)
171174
names.skills.push(row.name)
172175
}
176+
if (inserts.length > 0) await tx.insert(skill).values(inserts)
173177
}
174178

175179
if (selection.mcpServers.length > 0) {
@@ -186,32 +190,34 @@ export async function copyForkResourceContainers(
186190
// `generateMcpServerId` is deterministic on (workspace, url), so two selected
187191
// servers with the same normalized URL derive the same child id. Insert once
188192
// and map both source ids to the surviving child rather than aborting the fork.
189-
const insertedMcpIds = new Set<string>()
193+
const insertsByChildId = new Map<string, typeof mcpServers.$inferInsert>()
190194
for (const row of rows) {
191195
const childId = row.url ? generateMcpServerId(childWorkspaceId, row.url) : generateId()
192196
record('mcp_server', row.id, childId)
193-
if (insertedMcpIds.has(childId)) continue
194-
insertedMcpIds.add(childId)
197+
if (insertsByChildId.has(childId)) continue
195198
names.mcpServers.push(row.name)
199+
insertsByChildId.set(childId, {
200+
...row,
201+
id: childId,
202+
workspaceId: childWorkspaceId,
203+
createdBy: userId,
204+
// Secrets are never copied across workspaces: drop the registered OAuth
205+
// client + any auth headers so the child re-authenticates from scratch.
206+
oauthClientId: null,
207+
oauthClientSecret: null,
208+
headers: {},
209+
connectionStatus: 'disconnected',
210+
lastConnected: null,
211+
lastError: null,
212+
deletedAt: null,
213+
createdAt: now,
214+
updatedAt: now,
215+
})
216+
}
217+
if (insertsByChildId.size > 0) {
196218
await tx
197219
.insert(mcpServers)
198-
.values({
199-
...row,
200-
id: childId,
201-
workspaceId: childWorkspaceId,
202-
createdBy: userId,
203-
// Secrets are never copied across workspaces: drop the registered OAuth
204-
// client + any auth headers so the child re-authenticates from scratch.
205-
oauthClientId: null,
206-
oauthClientSecret: null,
207-
headers: {},
208-
connectionStatus: 'disconnected',
209-
lastConnected: null,
210-
lastError: null,
211-
deletedAt: null,
212-
createdAt: now,
213-
updatedAt: now,
214-
})
220+
.values([...insertsByChildId.values()])
215221
.onConflictDoNothing()
216222
}
217223
}
@@ -227,13 +233,14 @@ export async function copyForkResourceContainers(
227233
isNull(userTableDefinitions.archivedAt)
228234
)
229235
)
236+
const inserts: (typeof userTableDefinitions.$inferInsert)[] = []
230237
for (const definition of definitions) {
231238
const childTableId = generateId()
232239
const remappedSchema = remapForkTableWorkflowGroups(
233240
definition.schema as TableSchema,
234241
workflowIdMap
235242
)
236-
await tx.insert(userTableDefinitions).values({
243+
inserts.push({
237244
...definition,
238245
id: childTableId,
239246
workspaceId: childWorkspaceId,
@@ -251,6 +258,7 @@ export async function copyForkResourceContainers(
251258
contentPlan.tables.push({ sourceId: definition.id, childId: childTableId })
252259
names.tables.push(definition.name)
253260
}
261+
if (inserts.length > 0) await tx.insert(userTableDefinitions).values(inserts)
254262
}
255263

256264
if (selection.knowledgeBases.length > 0) {
@@ -264,9 +272,10 @@ export async function copyForkResourceContainers(
264272
isNull(knowledgeBase.deletedAt)
265273
)
266274
)
275+
const inserts: (typeof knowledgeBase.$inferInsert)[] = []
267276
for (const base of bases) {
268277
const childKbId = generateId()
269-
await tx.insert(knowledgeBase).values({
278+
inserts.push({
270279
...base,
271280
id: childKbId,
272281
workspaceId: childWorkspaceId,
@@ -279,6 +288,7 @@ export async function copyForkResourceContainers(
279288
contentPlan.knowledgeBases.push({ sourceId: base.id, childId: childKbId })
280289
names.knowledgeBases.push(base.name)
281290
}
291+
if (inserts.length > 0) await tx.insert(knowledgeBase).values(inserts)
282292
}
283293

284294
return { idMap, mappingEntries, contentPlan, names }
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { describe, expect, it } from 'vitest'
5+
import { buildWorkflowNameRegistry } from '@/lib/workspaces/fork/copy/copy-workflows'
6+
7+
describe('buildWorkflowNameRegistry', () => {
8+
it('reports a name as taken by another workflow in the same folder', () => {
9+
const reg = buildWorkflowNameRegistry([{ id: 'w1', folderId: 'f1', name: 'Onboarding' }])
10+
expect(reg.isTaken('f1', 'Onboarding', null)).toBe(true)
11+
expect(reg.isTaken('f1', 'Onboarding', 'w2')).toBe(true)
12+
})
13+
14+
it('excludes the workflow itself so a replace can keep its own name', () => {
15+
const reg = buildWorkflowNameRegistry([{ id: 'w1', folderId: 'f1', name: 'Onboarding' }])
16+
expect(reg.isTaken('f1', 'Onboarding', 'w1')).toBe(false)
17+
})
18+
19+
it('is folder-scoped: the same name in another folder is free', () => {
20+
const reg = buildWorkflowNameRegistry([{ id: 'w1', folderId: 'f1', name: 'Onboarding' }])
21+
expect(reg.isTaken('f2', 'Onboarding', null)).toBe(false)
22+
expect(reg.isTaken(null, 'Onboarding', null)).toBe(false)
23+
})
24+
25+
it('treats the root (null) folder distinctly, matching coalesce(folderId, "")', () => {
26+
const reg = buildWorkflowNameRegistry([{ id: 'w1', folderId: null, name: 'Root WF' }])
27+
expect(reg.isTaken(null, 'Root WF', null)).toBe(true)
28+
expect(reg.isTaken('f1', 'Root WF', null)).toBe(false)
29+
})
30+
31+
it('claims a new name so a later workflow in the same copy loop sees it taken', () => {
32+
const reg = buildWorkflowNameRegistry([])
33+
expect(reg.isTaken('f1', 'Report', null)).toBe(false)
34+
reg.claim('f1', 'Report', 'wA')
35+
expect(reg.isTaken('f1', 'Report', null)).toBe(true)
36+
expect(reg.isTaken('f1', 'Report', 'wA')).toBe(false)
37+
})
38+
39+
it('releases the prior name when a workflow is renamed (claim moves keys)', () => {
40+
const reg = buildWorkflowNameRegistry([{ id: 'w1', folderId: 'f1', name: 'Old' }])
41+
reg.claim('f1', 'New', 'w1')
42+
expect(reg.isTaken('f1', 'Old', null)).toBe(false)
43+
expect(reg.isTaken('f1', 'New', null)).toBe(true)
44+
})
45+
46+
it('re-claiming the same (folder, name) is a no-op', () => {
47+
const reg = buildWorkflowNameRegistry([{ id: 'w1', folderId: 'f1', name: 'Same' }])
48+
reg.claim('f1', 'Same', 'w1')
49+
expect(reg.isTaken('f1', 'Same', 'w1')).toBe(false)
50+
expect(reg.isTaken('f1', 'Same', null)).toBe(true)
51+
})
52+
53+
it('handles multiple holders (legacy duplicates) and partial release', () => {
54+
const reg = buildWorkflowNameRegistry([
55+
{ id: 'w1', folderId: 'f1', name: 'Dup' },
56+
{ id: 'w2', folderId: 'f1', name: 'Dup' },
57+
])
58+
expect(reg.isTaken('f1', 'Dup', 'w1')).toBe(true)
59+
reg.claim('f1', 'Other', 'w2')
60+
expect(reg.isTaken('f1', 'Dup', 'w1')).toBe(false)
61+
})
62+
})

0 commit comments

Comments
 (0)