Skip to content

Commit 8e43774

Browse files
improvement(sockets): position persistence on drag end, perms call only on joining room (#1571)
1 parent 715f42c commit 8e43774

File tree

11 files changed

+162
-123
lines changed

11 files changed

+162
-123
lines changed

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1422,7 +1422,7 @@ const WorkflowContent = React.memo(() => {
14221422
setDraggedNodeId(node.id)
14231423

14241424
// Emit collaborative position update during drag for smooth real-time movement
1425-
collaborativeUpdateBlockPosition(node.id, node.position)
1425+
collaborativeUpdateBlockPosition(node.id, node.position, false)
14261426

14271427
// Get the current parent ID of the node being dragged
14281428
const currentParentId = blocks[node.id]?.data?.parentId || null
@@ -1608,7 +1608,7 @@ const WorkflowContent = React.memo(() => {
16081608

16091609
// Emit collaborative position update for the final position
16101610
// This ensures other users see the smooth final position
1611-
collaborativeUpdateBlockPosition(node.id, node.position)
1611+
collaborativeUpdateBlockPosition(node.id, node.position, true)
16121612

16131613
// Record single move entry on drag end to avoid micro-moves
16141614
try {

apps/sim/contexts/socket-context.tsx

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -600,30 +600,45 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
600600

601601
// Apply light throttling only to position updates for smooth collaborative experience
602602
const isPositionUpdate = operation === 'update-position' && target === 'block'
603+
const { commit = true } = payload || {}
603604

604605
if (isPositionUpdate && payload.id) {
605606
const blockId = payload.id
606607

607-
// Store the latest position update
608+
if (commit) {
609+
socket.emit('workflow-operation', {
610+
operation,
611+
target,
612+
payload,
613+
timestamp: Date.now(),
614+
operationId,
615+
})
616+
pendingPositionUpdates.current.delete(blockId)
617+
const timeoutId = positionUpdateTimeouts.current.get(blockId)
618+
if (timeoutId) {
619+
clearTimeout(timeoutId)
620+
positionUpdateTimeouts.current.delete(blockId)
621+
}
622+
return
623+
}
624+
608625
pendingPositionUpdates.current.set(blockId, {
609626
operation,
610627
target,
611628
payload,
612629
timestamp: Date.now(),
613-
operationId, // Include operation ID for queue tracking
630+
operationId,
614631
})
615632

616-
// Check if we already have a pending timeout for this block
617633
if (!positionUpdateTimeouts.current.has(blockId)) {
618-
// Schedule emission with optimized throttling (30fps = ~33ms) to reduce DB load
619634
const timeoutId = window.setTimeout(() => {
620635
const latestUpdate = pendingPositionUpdates.current.get(blockId)
621636
if (latestUpdate) {
622637
socket.emit('workflow-operation', latestUpdate)
623638
pendingPositionUpdates.current.delete(blockId)
624639
}
625640
positionUpdateTimeouts.current.delete(blockId)
626-
}, 33) // 30fps - good balance between smoothness and DB performance
641+
}, 33)
627642

628643
positionUpdateTimeouts.current.set(blockId, timeoutId)
629644
}

apps/sim/hooks/use-collaborative-workflow.ts

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -868,13 +868,19 @@ export function useCollaborativeWorkflow() {
868868
)
869869

870870
const collaborativeUpdateBlockPosition = useCallback(
871-
(id: string, position: Position) => {
872-
// Only apply position updates here (no undo recording to avoid micro-moves)
873-
executeQueuedDebouncedOperation('update-position', 'block', { id, position }, () =>
871+
(id: string, position: Position, commit = true) => {
872+
if (commit) {
873+
executeQueuedOperation('update-position', 'block', { id, position, commit }, () => {
874+
workflowStore.updateBlockPosition(id, position)
875+
})
876+
return
877+
}
878+
879+
executeQueuedDebouncedOperation('update-position', 'block', { id, position }, () => {
874880
workflowStore.updateBlockPosition(id, position)
875-
)
881+
})
876882
},
877-
[executeQueuedDebouncedOperation, workflowStore]
883+
[executeQueuedDebouncedOperation, executeQueuedOperation, workflowStore]
878884
)
879885

880886
const collaborativeUpdateBlockName = useCallback(

apps/sim/hooks/use-undo-redo.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -603,6 +603,7 @@ export function useUndoRedo() {
603603
id: moveOp.data.blockId,
604604
position: { x: moveOp.data.after.x, y: moveOp.data.after.y },
605605
parentId: moveOp.data.after.parentId,
606+
commit: true,
606607
isUndo: true,
607608
originalOpId: entry.id,
608609
},
@@ -706,6 +707,7 @@ export function useUndoRedo() {
706707
payload: {
707708
id: blockId,
708709
position: newPosition,
710+
commit: true,
709711
isUndo: true,
710712
originalOpId: entry.id,
711713
},

apps/sim/socket-server/database/operations.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -340,6 +340,10 @@ async function handleBlockOperationTx(
340340
throw new Error('Missing required fields for update position operation')
341341
}
342342

343+
if (payload.commit !== true) {
344+
return
345+
}
346+
343347
const updateResult = await tx
344348
.update(workflowBlocks)
345349
.set({

apps/sim/socket-server/handlers/operations.ts

Lines changed: 65 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { createLogger } from '@/lib/logs/console/logger'
33
import { persistWorkflowOperation } from '@/socket-server/database/operations'
44
import type { HandlerDependencies } from '@/socket-server/handlers/workflow'
55
import type { AuthenticatedSocket } from '@/socket-server/middleware/auth'
6-
import { verifyOperationPermission } from '@/socket-server/middleware/permissions'
6+
import { checkRolePermission } from '@/socket-server/middleware/permissions'
77
import type { RoomManager } from '@/socket-server/rooms/manager'
88
import { WorkflowOperationSchema } from '@/socket-server/validation/schemas'
99

@@ -43,36 +43,52 @@ export function setupOperationsHandlers(
4343
operationId = validatedOperation.operationId
4444
const { operation, target, payload, timestamp } = validatedOperation
4545

46-
// Check operation permissions
47-
const permissionCheck = await verifyOperationPermission(
48-
session.userId,
49-
workflowId,
50-
operation,
51-
target
52-
)
53-
if (!permissionCheck.allowed) {
54-
logger.warn(
55-
`User ${session.userId} forbidden from ${operation} on ${target}: ${permissionCheck.reason}`
56-
)
57-
socket.emit('operation-forbidden', {
58-
type: 'INSUFFICIENT_PERMISSIONS',
59-
message: permissionCheck.reason || 'Insufficient permissions for this operation',
60-
operation,
61-
target,
62-
})
63-
return
64-
}
65-
66-
const userPresence = room.users.get(socket.id)
67-
if (userPresence) {
68-
userPresence.lastActivity = Date.now()
69-
}
70-
7146
// For position updates, preserve client timestamp to maintain ordering
7247
// For other operations, use server timestamp for consistency
7348
const isPositionUpdate = operation === 'update-position' && target === 'block'
49+
const commitPositionUpdate =
50+
isPositionUpdate && 'commit' in payload ? payload.commit === true : false
7451
const operationTimestamp = isPositionUpdate ? timestamp : Date.now()
7552

53+
// Skip permission checks for non-committed position updates (broadcasts only, no persistence)
54+
if (isPositionUpdate && !commitPositionUpdate) {
55+
// Update last activity
56+
const userPresence = room.users.get(socket.id)
57+
if (userPresence) {
58+
userPresence.lastActivity = Date.now()
59+
}
60+
} else {
61+
// Check permissions from cached role for all other operations
62+
const userPresence = room.users.get(socket.id)
63+
if (!userPresence) {
64+
logger.warn(`User presence not found for socket ${socket.id}`)
65+
socket.emit('operation-forbidden', {
66+
type: 'SESSION_ERROR',
67+
message: 'User session not found',
68+
operation,
69+
target,
70+
})
71+
return
72+
}
73+
74+
userPresence.lastActivity = Date.now()
75+
76+
// Check permissions using cached role (no DB query)
77+
const permissionCheck = checkRolePermission(userPresence.role, operation)
78+
if (!permissionCheck.allowed) {
79+
logger.warn(
80+
`User ${session.userId} (role: ${userPresence.role}) forbidden from ${operation} on ${target}`
81+
)
82+
socket.emit('operation-forbidden', {
83+
type: 'INSUFFICIENT_PERMISSIONS',
84+
message: `${permissionCheck.reason} on '${target}'`,
85+
operation,
86+
target,
87+
})
88+
return
89+
}
90+
}
91+
7692
// Broadcast first for position updates to minimize latency, then persist
7793
// For other operations, persist first for consistency
7894
if (isPositionUpdate) {
@@ -94,36 +110,39 @@ export function setupOperationsHandlers(
94110

95111
socket.to(workflowId).emit('workflow-operation', broadcastData)
96112

97-
// Persist position update asynchronously to avoid blocking real-time updates
98-
persistWorkflowOperation(workflowId, {
99-
operation,
100-
target,
101-
payload,
102-
timestamp: operationTimestamp,
103-
userId: session.userId,
104-
}).catch((error) => {
113+
if (!commitPositionUpdate) {
114+
return
115+
}
116+
117+
try {
118+
await persistWorkflowOperation(workflowId, {
119+
operation,
120+
target,
121+
payload,
122+
timestamp: operationTimestamp,
123+
userId: session.userId,
124+
})
125+
room.lastModified = Date.now()
126+
127+
if (operationId) {
128+
socket.emit('operation-confirmed', {
129+
operationId,
130+
serverTimestamp: Date.now(),
131+
})
132+
}
133+
} catch (error) {
105134
logger.error('Failed to persist position update:', error)
106-
// Emit failure for position updates if operationId is provided
135+
107136
if (operationId) {
108137
socket.emit('operation-failed', {
109138
operationId,
110139
error: error instanceof Error ? error.message : 'Database persistence failed',
111140
retryable: true,
112141
})
113142
}
114-
})
115-
116-
room.lastModified = Date.now()
117-
118-
// Emit confirmation if operationId is provided
119-
if (operationId) {
120-
socket.emit('operation-confirmed', {
121-
operationId,
122-
serverTimestamp: Date.now(),
123-
})
124143
}
125144

126-
return // Early return for position updates
145+
return
127146
}
128147

129148
if (target === 'variable' && ['add', 'remove', 'duplicate'].includes(operation)) {

apps/sim/socket-server/handlers/workflow.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,13 +46,15 @@ export function setupWorkflowHandlers(
4646

4747
logger.info(`Join workflow request from ${userId} (${userName}) for workflow ${workflowId}`)
4848

49+
let userRole: string
4950
try {
5051
const accessInfo = await verifyWorkflowAccess(userId, workflowId)
5152
if (!accessInfo.hasAccess) {
5253
logger.warn(`User ${userId} (${userName}) denied access to workflow ${workflowId}`)
5354
socket.emit('join-workflow-error', { error: 'Access denied to workflow' })
5455
return
5556
}
57+
userRole = accessInfo.role || 'read'
5658
} catch (error) {
5759
logger.warn(`Error verifying workflow access for ${userId}:`, error)
5860
socket.emit('join-workflow-error', { error: 'Failed to verify workflow access' })
@@ -85,6 +87,7 @@ export function setupWorkflowHandlers(
8587
socketId: socket.id,
8688
joinedAt: Date.now(),
8789
lastActivity: Date.now(),
90+
role: userRole,
8891
}
8992

9093
room.users.set(socket.id, userPresence)

apps/sim/socket-server/index.test.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,9 +40,9 @@ vi.mock('@/socket-server/middleware/auth', () => ({
4040
vi.mock('@/socket-server/middleware/permissions', () => ({
4141
verifyWorkflowAccess: vi.fn().mockResolvedValue({
4242
hasAccess: true,
43-
role: 'owner',
43+
role: 'admin',
4444
}),
45-
verifyOperationPermission: vi.fn().mockResolvedValue({
45+
checkRolePermission: vi.fn().mockReturnValue({
4646
allowed: true,
4747
}),
4848
}))
@@ -212,6 +212,7 @@ describe('Socket Server Index Integration', () => {
212212
socketId,
213213
joinedAt: Date.now(),
214214
lastActivity: Date.now(),
215+
role: 'admin',
215216
})
216217
room.activeConnections = 1
217218

0 commit comments

Comments
 (0)