chore: import upstream snapshot with attribution
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:20:55 +08:00
commit d25d482dc2
13754 changed files with 4996608 additions and 0 deletions
+36
View File
@@ -0,0 +1,36 @@
import { createLogger } from '@sim/logger'
import { del, get, set } from 'idb-keyval'
import type { StateStorage } from 'zustand/middleware'
const logger = createLogger('CodeUndoRedoStorage')
export const codeUndoRedoStorage: StateStorage = {
getItem: async (name: string): Promise<string | null> => {
if (typeof window === 'undefined') return null
try {
const value = await get<string>(name)
return value ?? null
} catch (error) {
logger.warn('IndexedDB read failed', { name, error })
return null
}
},
setItem: async (name: string, value: string): Promise<void> => {
if (typeof window === 'undefined') return
try {
await set(name, value)
} catch (error) {
logger.warn('IndexedDB write failed', { name, error })
}
},
removeItem: async (name: string): Promise<void> => {
if (typeof window === 'undefined') return
try {
await del(name)
} catch (error) {
logger.warn('IndexedDB delete failed', { name, error })
}
},
}
+151
View File
@@ -0,0 +1,151 @@
import { create } from 'zustand'
import { createJSONStorage, devtools, persist } from 'zustand/middleware'
import { codeUndoRedoStorage } from '@/stores/undo-redo/code-storage'
interface CodeUndoRedoEntry {
id: string
createdAt: number
workflowId: string
blockId: string
subBlockId: string
before: string
after: string
}
interface CodeUndoRedoStack {
undo: CodeUndoRedoEntry[]
redo: CodeUndoRedoEntry[]
lastUpdated?: number
}
interface CodeUndoRedoState {
stacks: Record<string, CodeUndoRedoStack>
capacity: number
push: (entry: CodeUndoRedoEntry) => void
undo: (workflowId: string, blockId: string, subBlockId: string) => CodeUndoRedoEntry | null
redo: (workflowId: string, blockId: string, subBlockId: string) => CodeUndoRedoEntry | null
clear: (workflowId: string, blockId: string, subBlockId: string) => void
}
const DEFAULT_CAPACITY = 500
const MAX_STACKS = 50
function getStackKey(workflowId: string, blockId: string, subBlockId: string): string {
return `${workflowId}:${blockId}:${subBlockId}`
}
const initialState = {
stacks: {} as Record<string, CodeUndoRedoStack>,
capacity: DEFAULT_CAPACITY,
}
export const useCodeUndoRedoStore = create<CodeUndoRedoState>()(
devtools(
persist(
(set, get) => ({
...initialState,
push: (entry) => {
if (entry.before === entry.after) return
const state = get()
const key = getStackKey(entry.workflowId, entry.blockId, entry.subBlockId)
const currentStacks = { ...state.stacks }
const stackKeys = Object.keys(currentStacks)
if (stackKeys.length >= MAX_STACKS && !currentStacks[key]) {
let oldestKey: string | null = null
let oldestTime = Number.POSITIVE_INFINITY
for (const stackKey of stackKeys) {
const t = currentStacks[stackKey].lastUpdated ?? 0
if (t < oldestTime) {
oldestTime = t
oldestKey = stackKey
}
}
if (oldestKey) {
delete currentStacks[oldestKey]
}
}
const stack = currentStacks[key] || { undo: [], redo: [] }
const newUndo = [...stack.undo, entry]
if (newUndo.length > state.capacity) {
newUndo.shift()
}
currentStacks[key] = {
undo: newUndo,
redo: [],
lastUpdated: Date.now(),
}
set({ stacks: currentStacks })
},
undo: (workflowId, blockId, subBlockId) => {
const key = getStackKey(workflowId, blockId, subBlockId)
const state = get()
const stack = state.stacks[key]
if (!stack || stack.undo.length === 0) return null
const entry = stack.undo[stack.undo.length - 1]
const newUndo = stack.undo.slice(0, -1)
const newRedo = [...stack.redo, entry]
set({
stacks: {
...state.stacks,
[key]: {
undo: newUndo,
redo: newRedo.slice(-state.capacity),
lastUpdated: Date.now(),
},
},
})
return entry
},
redo: (workflowId, blockId, subBlockId) => {
const key = getStackKey(workflowId, blockId, subBlockId)
const state = get()
const stack = state.stacks[key]
if (!stack || stack.redo.length === 0) return null
const entry = stack.redo[stack.redo.length - 1]
const newRedo = stack.redo.slice(0, -1)
const newUndo = [...stack.undo, entry]
set({
stacks: {
...state.stacks,
[key]: {
undo: newUndo.slice(-state.capacity),
redo: newRedo,
lastUpdated: Date.now(),
},
},
})
return entry
},
clear: (workflowId, blockId, subBlockId) => {
const key = getStackKey(workflowId, blockId, subBlockId)
const state = get()
const { [key]: _, ...rest } = state.stacks
set({ stacks: rest })
},
}),
{
name: 'code-undo-redo-store',
storage: createJSONStorage(() => codeUndoRedoStorage),
partialize: (state) => ({
stacks: state.stacks,
capacity: state.capacity,
}),
}
),
{ name: 'code-undo-redo-store' }
)
)
+4
View File
@@ -0,0 +1,4 @@
export { useCodeUndoRedoStore } from './code-store'
export { runWithUndoRedoRecordingSuspended, useUndoRedoStore } from './store'
export * from './types'
export * from './utils'
+765
View File
@@ -0,0 +1,765 @@
/**
* Tests for the undo/redo store.
*
* These tests cover:
* - Basic push/undo/redo operations
* - Stack capacity limits
* - Move operation coalescing
* - Recording suspension
* - Stack pruning
* - Multi-workflow/user isolation
*/
import {
createAddBlockEntry,
createAddEdgeEntry,
createBatchRemoveEdgesEntry,
createBlock,
createMockStorage,
createMoveBlockEntry,
createRemoveBlockEntry,
createUpdateParentEntry,
} from '@sim/testing'
import { beforeEach, describe, expect, it } from 'vitest'
import { runWithUndoRedoRecordingSuspended, useUndoRedoStore } from '@/stores/undo-redo/store'
import type { UpdateParentOperation } from '@/stores/undo-redo/types'
describe('useUndoRedoStore', () => {
const workflowId = 'wf-test'
const userId = 'user-test'
beforeEach(() => {
global.localStorage = createMockStorage()
useUndoRedoStore.setState({
stacks: {},
capacity: 100,
})
})
describe('push', () => {
it('should add an operation to the undo stack', () => {
const { push, getStackSizes } = useUndoRedoStore.getState()
const entry = createAddBlockEntry('block-1', { workflowId, userId })
push(workflowId, userId, entry)
expect(getStackSizes(workflowId, userId)).toEqual({
undoSize: 1,
redoSize: 0,
})
})
it('should clear redo stack when pushing new operation', () => {
const { push, undo, getStackSizes } = useUndoRedoStore.getState()
push(workflowId, userId, createAddBlockEntry('block-1', { workflowId, userId }))
push(workflowId, userId, createAddBlockEntry('block-2', { workflowId, userId }))
undo(workflowId, userId)
expect(getStackSizes(workflowId, userId).redoSize).toBe(1)
push(workflowId, userId, createAddBlockEntry('block-3', { workflowId, userId }))
expect(getStackSizes(workflowId, userId)).toEqual({
undoSize: 2,
redoSize: 0,
})
})
it('should respect capacity limit', () => {
useUndoRedoStore.setState({ capacity: 3 })
const { push, getStackSizes } = useUndoRedoStore.getState()
for (let i = 0; i < 5; i++) {
push(workflowId, userId, createAddBlockEntry(`block-${i}`, { workflowId, userId }))
}
expect(getStackSizes(workflowId, userId).undoSize).toBe(3)
})
it('should limit number of stacks to 5', () => {
const { push } = useUndoRedoStore.getState()
// Create 6 different workflow/user combinations
for (let i = 0; i < 6; i++) {
const wfId = `wf-${i}`
const uId = `user-${i}`
push(wfId, uId, createAddBlockEntry(`block-${i}`, { workflowId: wfId, userId: uId }))
}
const { stacks } = useUndoRedoStore.getState()
expect(Object.keys(stacks).length).toBe(5)
})
it('should remove oldest stack when limit exceeded', () => {
const { push } = useUndoRedoStore.getState()
// Create stacks with varying timestamps
for (let i = 0; i < 5; i++) {
push(`wf-${i}`, `user-${i}`, createAddBlockEntry(`block-${i}`))
}
// Add a 6th stack - should remove the oldest
push('wf-new', 'user-new', createAddBlockEntry('block-new'))
const { stacks } = useUndoRedoStore.getState()
expect(Object.keys(stacks).length).toBe(5)
expect(stacks['wf-new:user-new']).toBeDefined()
})
})
describe('undo', () => {
it('should return the last operation and move it to redo', () => {
const { push, undo, getStackSizes } = useUndoRedoStore.getState()
const entry = createAddBlockEntry('block-1', { workflowId, userId })
push(workflowId, userId, entry)
const result = undo(workflowId, userId)
expect(result).toEqual(entry)
expect(getStackSizes(workflowId, userId)).toEqual({
undoSize: 0,
redoSize: 1,
})
})
it('should return null when undo stack is empty', () => {
const { undo } = useUndoRedoStore.getState()
const result = undo(workflowId, userId)
expect(result).toBeNull()
})
it('should undo operations in LIFO order', () => {
const { push, undo } = useUndoRedoStore.getState()
const entry1 = createAddBlockEntry('block-1', { workflowId, userId })
const entry2 = createAddBlockEntry('block-2', { workflowId, userId })
const entry3 = createAddBlockEntry('block-3', { workflowId, userId })
push(workflowId, userId, entry1)
push(workflowId, userId, entry2)
push(workflowId, userId, entry3)
expect(undo(workflowId, userId)).toEqual(entry3)
expect(undo(workflowId, userId)).toEqual(entry2)
expect(undo(workflowId, userId)).toEqual(entry1)
})
})
describe('redo', () => {
it('should return the last undone operation and move it back to undo', () => {
const { push, undo, redo, getStackSizes } = useUndoRedoStore.getState()
const entry = createAddBlockEntry('block-1', { workflowId, userId })
push(workflowId, userId, entry)
undo(workflowId, userId)
const result = redo(workflowId, userId)
expect(result).toEqual(entry)
expect(getStackSizes(workflowId, userId)).toEqual({
undoSize: 1,
redoSize: 0,
})
})
it('should return null when redo stack is empty', () => {
const { redo } = useUndoRedoStore.getState()
const result = redo(workflowId, userId)
expect(result).toBeNull()
})
it('should redo operations in LIFO order', () => {
const { push, undo, redo } = useUndoRedoStore.getState()
const entry1 = createAddBlockEntry('block-1', { workflowId, userId })
const entry2 = createAddBlockEntry('block-2', { workflowId, userId })
push(workflowId, userId, entry1)
push(workflowId, userId, entry2)
undo(workflowId, userId)
undo(workflowId, userId)
expect(redo(workflowId, userId)).toEqual(entry1)
expect(redo(workflowId, userId)).toEqual(entry2)
})
})
describe('clear', () => {
it('should clear both undo and redo stacks', () => {
const { push, undo, clear, getStackSizes } = useUndoRedoStore.getState()
push(workflowId, userId, createAddBlockEntry('block-1', { workflowId, userId }))
push(workflowId, userId, createAddBlockEntry('block-2', { workflowId, userId }))
undo(workflowId, userId)
clear(workflowId, userId)
expect(getStackSizes(workflowId, userId)).toEqual({
undoSize: 0,
redoSize: 0,
})
})
it('should only clear stacks for specified workflow/user', () => {
const { push, clear, getStackSizes } = useUndoRedoStore.getState()
push(
'wf-1',
'user-1',
createAddBlockEntry('block-1', { workflowId: 'wf-1', userId: 'user-1' })
)
push(
'wf-2',
'user-2',
createAddBlockEntry('block-2', { workflowId: 'wf-2', userId: 'user-2' })
)
clear('wf-1', 'user-1')
expect(getStackSizes('wf-1', 'user-1').undoSize).toBe(0)
expect(getStackSizes('wf-2', 'user-2').undoSize).toBe(1)
})
})
describe('clearRedo', () => {
it('should only clear the redo stack', () => {
const { push, undo, clearRedo, getStackSizes } = useUndoRedoStore.getState()
push(workflowId, userId, createAddBlockEntry('block-1', { workflowId, userId }))
push(workflowId, userId, createAddBlockEntry('block-2', { workflowId, userId }))
undo(workflowId, userId)
clearRedo(workflowId, userId)
expect(getStackSizes(workflowId, userId)).toEqual({
undoSize: 1,
redoSize: 0,
})
})
})
describe('getStackSizes', () => {
it('should return zero sizes for non-existent stack', () => {
const { getStackSizes } = useUndoRedoStore.getState()
expect(getStackSizes('non-existent', 'user')).toEqual({
undoSize: 0,
redoSize: 0,
})
})
it('should return correct sizes', () => {
const { push, undo, getStackSizes } = useUndoRedoStore.getState()
push(workflowId, userId, createAddBlockEntry('block-1', { workflowId, userId }))
push(workflowId, userId, createAddBlockEntry('block-2', { workflowId, userId }))
push(workflowId, userId, createAddBlockEntry('block-3', { workflowId, userId }))
undo(workflowId, userId)
expect(getStackSizes(workflowId, userId)).toEqual({
undoSize: 2,
redoSize: 1,
})
})
})
describe('setCapacity', () => {
it('should update capacity', () => {
const { setCapacity } = useUndoRedoStore.getState()
setCapacity(50)
expect(useUndoRedoStore.getState().capacity).toBe(50)
})
it('should truncate existing stacks to new capacity', () => {
const { push, setCapacity, getStackSizes } = useUndoRedoStore.getState()
for (let i = 0; i < 10; i++) {
push(workflowId, userId, createAddBlockEntry(`block-${i}`, { workflowId, userId }))
}
expect(getStackSizes(workflowId, userId).undoSize).toBe(10)
setCapacity(5)
expect(getStackSizes(workflowId, userId).undoSize).toBe(5)
})
})
describe('move-block coalescing', () => {
it('should coalesce consecutive moves of the same block', () => {
const { push, getStackSizes } = useUndoRedoStore.getState()
push(
workflowId,
userId,
createMoveBlockEntry('block-1', {
workflowId,
userId,
before: { x: 0, y: 0 },
after: { x: 10, y: 10 },
})
)
push(
workflowId,
userId,
createMoveBlockEntry('block-1', {
workflowId,
userId,
before: { x: 10, y: 10 },
after: { x: 20, y: 20 },
})
)
// Should coalesce into a single operation
expect(getStackSizes(workflowId, userId).undoSize).toBe(1)
})
it('should not coalesce moves of different blocks', () => {
const { push, getStackSizes } = useUndoRedoStore.getState()
push(
workflowId,
userId,
createMoveBlockEntry('block-1', {
workflowId,
userId,
before: { x: 0, y: 0 },
after: { x: 10, y: 10 },
})
)
push(
workflowId,
userId,
createMoveBlockEntry('block-2', {
workflowId,
userId,
before: { x: 0, y: 0 },
after: { x: 20, y: 20 },
})
)
expect(getStackSizes(workflowId, userId).undoSize).toBe(2)
})
it('should skip no-op moves', () => {
const { push, getStackSizes } = useUndoRedoStore.getState()
push(
workflowId,
userId,
createMoveBlockEntry('block-1', {
workflowId,
userId,
before: { x: 100, y: 100 },
after: { x: 100, y: 100 },
})
)
expect(getStackSizes(workflowId, userId).undoSize).toBe(0)
})
it('should preserve original position when coalescing results in no-op', () => {
const { push, getStackSizes } = useUndoRedoStore.getState()
// Move block from (0,0) to (10,10)
push(
workflowId,
userId,
createMoveBlockEntry('block-1', {
workflowId,
userId,
before: { x: 0, y: 0 },
after: { x: 10, y: 10 },
})
)
// Move block back to (0,0) - coalesces to a no-op
push(
workflowId,
userId,
createMoveBlockEntry('block-1', {
workflowId,
userId,
before: { x: 10, y: 10 },
after: { x: 0, y: 0 },
})
)
// Should result in no operations since it's a round-trip
expect(getStackSizes(workflowId, userId).undoSize).toBe(0)
})
})
describe('recording suspension', () => {
it('should skip operations when recording is suspended', async () => {
const { push, getStackSizes } = useUndoRedoStore.getState()
await runWithUndoRedoRecordingSuspended(() => {
push(workflowId, userId, createAddBlockEntry('block-1', { workflowId, userId }))
})
expect(getStackSizes(workflowId, userId).undoSize).toBe(0)
})
it('should resume recording after suspension ends', async () => {
const { push, getStackSizes } = useUndoRedoStore.getState()
await runWithUndoRedoRecordingSuspended(() => {
push(workflowId, userId, createAddBlockEntry('block-1', { workflowId, userId }))
})
push(workflowId, userId, createAddBlockEntry('block-2', { workflowId, userId }))
expect(getStackSizes(workflowId, userId).undoSize).toBe(1)
})
it('should handle nested suspension correctly', async () => {
const { push, getStackSizes } = useUndoRedoStore.getState()
await runWithUndoRedoRecordingSuspended(async () => {
push(workflowId, userId, createAddBlockEntry('block-1', { workflowId, userId }))
await runWithUndoRedoRecordingSuspended(() => {
push(workflowId, userId, createAddBlockEntry('block-2', { workflowId, userId }))
})
push(workflowId, userId, createAddBlockEntry('block-3', { workflowId, userId }))
})
expect(getStackSizes(workflowId, userId).undoSize).toBe(0)
push(workflowId, userId, createAddBlockEntry('block-4', { workflowId, userId }))
expect(getStackSizes(workflowId, userId).undoSize).toBe(1)
})
})
describe('pruneInvalidEntries', () => {
it('should remove entries for non-existent blocks', () => {
const { push, pruneInvalidEntries, getStackSizes } = useUndoRedoStore.getState()
// Add entries for blocks
push(workflowId, userId, createRemoveBlockEntry('block-1', null, { workflowId, userId }))
push(workflowId, userId, createRemoveBlockEntry('block-2', null, { workflowId, userId }))
expect(getStackSizes(workflowId, userId).undoSize).toBe(2)
// Prune with only block-1 existing
const graph = {
blocksById: {
'block-1': createBlock({ id: 'block-1' }),
},
edgesById: {},
}
pruneInvalidEntries(workflowId, userId, graph)
// Only the entry for block-1 should remain (inverse is add-block which requires block NOT exist)
// Actually, remove-block inverse is add-block, which is applicable when block doesn't exist
// Let me reconsider: the pruneInvalidEntries checks if the INVERSE is applicable
// For remove-block, inverse is add-block, which is applicable when block doesn't exist
expect(getStackSizes(workflowId, userId).undoSize).toBe(1)
})
it('should remove redo entries with non-applicable operations', () => {
const { push, undo, pruneInvalidEntries, getStackSizes } = useUndoRedoStore.getState()
push(workflowId, userId, createRemoveBlockEntry('block-1', null, { workflowId, userId }))
undo(workflowId, userId)
expect(getStackSizes(workflowId, userId).redoSize).toBe(1)
// Prune - block-1 doesn't exist, so remove-block is not applicable
pruneInvalidEntries(workflowId, userId, { blocksById: {}, edgesById: {} })
expect(getStackSizes(workflowId, userId).redoSize).toBe(0)
})
})
describe('workflow/user isolation', () => {
it('should keep stacks isolated by workflow and user', () => {
const { push, getStackSizes } = useUndoRedoStore.getState()
push(
'wf-1',
'user-1',
createAddBlockEntry('block-1', { workflowId: 'wf-1', userId: 'user-1' })
)
push(
'wf-1',
'user-2',
createAddBlockEntry('block-2', { workflowId: 'wf-1', userId: 'user-2' })
)
push(
'wf-2',
'user-1',
createAddBlockEntry('block-3', { workflowId: 'wf-2', userId: 'user-1' })
)
expect(getStackSizes('wf-1', 'user-1').undoSize).toBe(1)
expect(getStackSizes('wf-1', 'user-2').undoSize).toBe(1)
expect(getStackSizes('wf-2', 'user-1').undoSize).toBe(1)
})
it('should not affect other stacks when undoing', () => {
const { push, undo, getStackSizes } = useUndoRedoStore.getState()
push(
'wf-1',
'user-1',
createAddBlockEntry('block-1', { workflowId: 'wf-1', userId: 'user-1' })
)
push(
'wf-2',
'user-1',
createAddBlockEntry('block-2', { workflowId: 'wf-2', userId: 'user-1' })
)
undo('wf-1', 'user-1')
expect(getStackSizes('wf-1', 'user-1').undoSize).toBe(0)
expect(getStackSizes('wf-2', 'user-1').undoSize).toBe(1)
})
})
describe('edge cases', () => {
it('should handle rapid consecutive operations', () => {
const { push, getStackSizes } = useUndoRedoStore.getState()
for (let i = 0; i < 50; i++) {
push(workflowId, userId, createAddBlockEntry(`block-${i}`, { workflowId, userId }))
}
expect(getStackSizes(workflowId, userId).undoSize).toBe(50)
})
it('should handle multiple undo/redo cycles', () => {
const { push, undo, redo, getStackSizes } = useUndoRedoStore.getState()
push(workflowId, userId, createAddBlockEntry('block-1', { workflowId, userId }))
for (let i = 0; i < 10; i++) {
undo(workflowId, userId)
redo(workflowId, userId)
}
expect(getStackSizes(workflowId, userId)).toEqual({
undoSize: 1,
redoSize: 0,
})
})
it('should handle mixed operation types', () => {
const { push, undo, getStackSizes } = useUndoRedoStore.getState()
push(workflowId, userId, createAddBlockEntry('block-1', { workflowId, userId }))
push(workflowId, userId, createAddEdgeEntry('edge-1', { workflowId, userId }))
push(
workflowId,
userId,
createMoveBlockEntry('block-1', {
workflowId,
userId,
before: { x: 0, y: 0 },
after: { x: 100, y: 100 },
})
)
push(workflowId, userId, createRemoveBlockEntry('block-2', null, { workflowId, userId }))
expect(getStackSizes(workflowId, userId).undoSize).toBe(4)
undo(workflowId, userId)
undo(workflowId, userId)
expect(getStackSizes(workflowId, userId)).toEqual({
undoSize: 2,
redoSize: 2,
})
})
})
describe('edge operations', () => {
it('should handle add-edge operations', () => {
const { push, undo, redo, getStackSizes } = useUndoRedoStore.getState()
push(workflowId, userId, createAddEdgeEntry('edge-1', { workflowId, userId }))
push(workflowId, userId, createAddEdgeEntry('edge-2', { workflowId, userId }))
expect(getStackSizes(workflowId, userId).undoSize).toBe(2)
const entry = undo(workflowId, userId)
expect(entry?.operation.type).toBe('batch-add-edges')
expect(getStackSizes(workflowId, userId).redoSize).toBe(1)
redo(workflowId, userId)
expect(getStackSizes(workflowId, userId).undoSize).toBe(2)
})
it('should handle batch-remove-edges operations', () => {
const { push, undo, getStackSizes } = useUndoRedoStore.getState()
const edgeSnapshot = { id: 'edge-1', source: 'block-1', target: 'block-2' }
push(workflowId, userId, createBatchRemoveEdgesEntry([edgeSnapshot], { workflowId, userId }))
expect(getStackSizes(workflowId, userId).undoSize).toBe(1)
const entry = undo(workflowId, userId)
expect(entry?.operation.type).toBe('batch-remove-edges')
expect(entry?.inverse.type).toBe('batch-add-edges')
})
})
describe('update-parent operations', () => {
it('should handle update-parent operations', () => {
const { push, undo, redo, getStackSizes } = useUndoRedoStore.getState()
push(
workflowId,
userId,
createUpdateParentEntry('block-1', {
workflowId,
userId,
oldParentId: undefined,
newParentId: 'loop-1',
oldPosition: { x: 100, y: 100 },
newPosition: { x: 50, y: 50 },
})
)
expect(getStackSizes(workflowId, userId).undoSize).toBe(1)
const entry = undo(workflowId, userId)
expect(entry?.operation.type).toBe('update-parent')
expect(entry?.inverse.type).toBe('update-parent')
redo(workflowId, userId)
expect(getStackSizes(workflowId, userId).undoSize).toBe(1)
})
it('should correctly swap parent IDs in inverse operation', () => {
const { push, undo } = useUndoRedoStore.getState()
push(
workflowId,
userId,
createUpdateParentEntry('block-1', {
workflowId,
userId,
oldParentId: 'loop-1',
newParentId: 'loop-2',
oldPosition: { x: 0, y: 0 },
newPosition: { x: 100, y: 100 },
})
)
const entry = undo(workflowId, userId)
const inverse = entry?.inverse as UpdateParentOperation
expect(inverse.data.oldParentId).toBe('loop-2')
expect(inverse.data.newParentId).toBe('loop-1')
expect(inverse.data.oldPosition).toEqual({ x: 100, y: 100 })
expect(inverse.data.newPosition).toEqual({ x: 0, y: 0 })
})
})
describe('pruneInvalidEntries with edges', () => {
it('should remove entries for non-existent edges', () => {
const { push, pruneInvalidEntries, getStackSizes } = useUndoRedoStore.getState()
const edge1 = { id: 'edge-1', source: 'a', target: 'b' }
const edge2 = { id: 'edge-2', source: 'c', target: 'd' }
push(workflowId, userId, createBatchRemoveEdgesEntry([edge1], { workflowId, userId }))
push(workflowId, userId, createBatchRemoveEdgesEntry([edge2], { workflowId, userId }))
expect(getStackSizes(workflowId, userId).undoSize).toBe(2)
const graph = {
blocksById: {},
edgesById: {
'edge-1': { id: 'edge-1', source: 'a', target: 'b' },
},
}
pruneInvalidEntries(workflowId, userId, graph as any)
// edge-1 exists in graph, so we can't undo its removal (can't add it back) → pruned
// edge-2 doesn't exist, so we can undo its removal (can add it back) → kept
expect(getStackSizes(workflowId, userId).undoSize).toBe(1)
})
})
describe('complex scenarios', () => {
it('should handle a complete workflow creation scenario', () => {
const { push, undo, redo, getStackSizes } = useUndoRedoStore.getState()
push(workflowId, userId, createAddBlockEntry('starter', { workflowId, userId }))
push(workflowId, userId, createAddBlockEntry('agent-1', { workflowId, userId }))
push(workflowId, userId, createAddEdgeEntry('edge-1', { workflowId, userId }))
push(
workflowId,
userId,
createMoveBlockEntry('agent-1', {
workflowId,
userId,
before: { x: 0, y: 0 },
after: { x: 200, y: 100 },
})
)
expect(getStackSizes(workflowId, userId).undoSize).toBe(4)
undo(workflowId, userId)
undo(workflowId, userId)
expect(getStackSizes(workflowId, userId)).toEqual({ undoSize: 2, redoSize: 2 })
redo(workflowId, userId)
expect(getStackSizes(workflowId, userId)).toEqual({ undoSize: 3, redoSize: 1 })
push(workflowId, userId, createAddBlockEntry('agent-2', { workflowId, userId }))
expect(getStackSizes(workflowId, userId)).toEqual({ undoSize: 4, redoSize: 0 })
})
it('should handle loop workflow with child blocks', () => {
const { push, undo, getStackSizes } = useUndoRedoStore.getState()
push(workflowId, userId, createAddBlockEntry('loop-1', { workflowId, userId }))
push(
workflowId,
userId,
createUpdateParentEntry('child-1', {
workflowId,
userId,
oldParentId: undefined,
newParentId: 'loop-1',
})
)
push(
workflowId,
userId,
createMoveBlockEntry('child-1', {
workflowId,
userId,
before: { x: 0, y: 0 },
after: { x: 50, y: 50 },
})
)
expect(getStackSizes(workflowId, userId).undoSize).toBe(3)
const moveEntry = undo(workflowId, userId)
expect(moveEntry?.operation.type).toBe('batch-move-blocks')
const parentEntry = undo(workflowId, userId)
expect(parentEntry?.operation.type).toBe('update-parent')
})
})
})
+511
View File
@@ -0,0 +1,511 @@
import { createLogger } from '@sim/logger'
import { UNDO_REDO_OPERATIONS } from '@sim/realtime-protocol/constants'
import type { Edge } from 'reactflow'
import { create } from 'zustand'
import { createJSONStorage, persist } from 'zustand/middleware'
import type {
BatchAddBlocksOperation,
BatchAddEdgesOperation,
BatchMoveBlocksOperation,
BatchRemoveBlocksOperation,
BatchRemoveEdgesOperation,
BatchUpdateParentOperation,
Operation,
OperationEntry,
UndoRedoState,
} from '@/stores/undo-redo/types'
import type { BlockState } from '@/stores/workflows/workflow/types'
const logger = createLogger('UndoRedoStore')
const DEFAULT_CAPACITY = 100
const MAX_STACKS = 5
let recordingSuspendDepth = 0
function isRecordingSuspended(): boolean {
return recordingSuspendDepth > 0
}
/**
* Temporarily suspends undo/redo recording while the provided callback runs.
*
* @param callback - Function to execute while recording is disabled.
* @returns The callback result.
*/
export async function runWithUndoRedoRecordingSuspended<T>(
callback: () => Promise<T> | T
): Promise<T> {
recordingSuspendDepth += 1
try {
return await Promise.resolve(callback())
} finally {
recordingSuspendDepth = Math.max(0, recordingSuspendDepth - 1)
}
}
function getStackKey(workflowId: string, userId: string): string {
return `${workflowId}:${userId}`
}
/**
* Custom storage adapter for Zustand's persist middleware.
* We need this wrapper to gracefully handle 'QuotaExceededError' when localStorage is full,
* Without this, the default storage engine would throw and crash the application.
* and to properly handle SSR/Node.js environments.
*/
const safeStorageAdapter = {
getItem: (name: string): string | null => {
if (typeof localStorage === 'undefined') return null
try {
return localStorage.getItem(name)
} catch (e) {
logger.warn('Failed to read from localStorage', e)
return null
}
},
setItem: (name: string, value: string): void => {
if (typeof localStorage === 'undefined') return
try {
localStorage.setItem(name, value)
} catch (e) {
// Log warning but don't crash - this handles QuotaExceededError
logger.warn('Failed to save to localStorage', e)
}
},
removeItem: (name: string): void => {
if (typeof localStorage === 'undefined') return
try {
localStorage.removeItem(name)
} catch (e) {
logger.warn('Failed to remove from localStorage', e)
}
},
}
function isOperationApplicable(
operation: Operation,
graph: { blocksById: Record<string, BlockState>; edgesById: Record<string, Edge> }
): boolean {
switch (operation.type) {
case UNDO_REDO_OPERATIONS.BATCH_REMOVE_BLOCKS: {
const op = operation as BatchRemoveBlocksOperation
return op.data.blockSnapshots.every((block) => Boolean(graph.blocksById[block.id]))
}
case UNDO_REDO_OPERATIONS.BATCH_ADD_BLOCKS: {
const op = operation as BatchAddBlocksOperation
return op.data.blockSnapshots.every((block) => !graph.blocksById[block.id])
}
case UNDO_REDO_OPERATIONS.BATCH_MOVE_BLOCKS: {
const op = operation as BatchMoveBlocksOperation
return op.data.moves.every((move) => Boolean(graph.blocksById[move.blockId]))
}
case UNDO_REDO_OPERATIONS.UPDATE_PARENT: {
const blockId = operation.data.blockId
return Boolean(graph.blocksById[blockId])
}
case UNDO_REDO_OPERATIONS.BATCH_UPDATE_PARENT: {
const op = operation as BatchUpdateParentOperation
return op.data.updates.every((u) => Boolean(graph.blocksById[u.blockId]))
}
case UNDO_REDO_OPERATIONS.BATCH_REMOVE_EDGES: {
const op = operation as BatchRemoveEdgesOperation
return op.data.edgeSnapshots.every((edge) => Boolean(graph.edgesById[edge.id]))
}
case UNDO_REDO_OPERATIONS.BATCH_ADD_EDGES: {
const op = operation as BatchAddEdgesOperation
return op.data.edgeSnapshots.every((edge) => !graph.edgesById[edge.id])
}
default:
return true
}
}
export const useUndoRedoStore = create<UndoRedoState>()(
persist(
(set, get) => ({
stacks: {},
capacity: DEFAULT_CAPACITY,
push: (workflowId: string, userId: string, entry: OperationEntry) => {
if (isRecordingSuspended()) {
logger.debug('Skipped push while undo/redo recording suspended', {
workflowId,
userId,
operationType: entry.operation.type,
})
return
}
const key = getStackKey(workflowId, userId)
const state = get()
const currentStacks = { ...state.stacks }
// Limit number of stacks
const stackKeys = Object.keys(currentStacks)
if (stackKeys.length >= MAX_STACKS && !currentStacks[key]) {
let oldestKey: string | null = null
let oldestTime = Number.POSITIVE_INFINITY
for (const k of stackKeys) {
const t = currentStacks[k].lastUpdated ?? 0
if (t < oldestTime) {
oldestTime = t
oldestKey = k
}
}
if (oldestKey) {
delete currentStacks[oldestKey]
}
}
const stack = currentStacks[key] || { undo: [], redo: [] }
// Prevent duplicate diff operations (apply-diff, accept-diff, reject-diff)
if (['apply-diff', 'accept-diff', 'reject-diff'].includes(entry.operation.type)) {
const lastEntry = stack.undo[stack.undo.length - 1]
if (lastEntry && lastEntry.operation.type === entry.operation.type) {
// Check if it's a duplicate by comparing the relevant state data
const lastData = lastEntry.operation.data as any
const newData = entry.operation.data as any
// For each diff operation type, check the relevant state
let isDuplicate = false
if (entry.operation.type === 'apply-diff') {
isDuplicate =
JSON.stringify(lastData.baselineSnapshot?.blocks) ===
JSON.stringify(newData.baselineSnapshot?.blocks) &&
JSON.stringify(lastData.proposedState?.blocks) ===
JSON.stringify(newData.proposedState?.blocks)
} else if (entry.operation.type === 'accept-diff') {
isDuplicate =
JSON.stringify(lastData.afterAccept?.blocks) ===
JSON.stringify(newData.afterAccept?.blocks)
} else if (entry.operation.type === 'reject-diff') {
isDuplicate =
JSON.stringify(lastData.afterReject?.blocks) ===
JSON.stringify(newData.afterReject?.blocks)
}
if (isDuplicate) {
logger.debug('Skipping duplicate diff operation', {
type: entry.operation.type,
workflowId,
userId,
})
return
}
}
}
// Coalesce consecutive batch-move-blocks operations for overlapping blocks
if (entry.operation.type === 'batch-move-blocks') {
const incoming = entry.operation as BatchMoveBlocksOperation
const last = stack.undo[stack.undo.length - 1]
// Skip no-op moves (all moves have same before/after)
const allNoOp = incoming.data.moves.every((move) => {
const sameParent = (move.before.parentId ?? null) === (move.after.parentId ?? null)
return move.before.x === move.after.x && move.before.y === move.after.y && sameParent
})
if (allNoOp) {
logger.debug('Skipped no-op batch move push')
return
}
if (
last &&
last.operation.type === 'batch-move-blocks' &&
last.inverse.type === 'batch-move-blocks'
) {
const prev = last.operation as BatchMoveBlocksOperation
const prevBlockIds = new Set(prev.data.moves.map((m) => m.blockId))
const incomingBlockIds = new Set(incoming.data.moves.map((m) => m.blockId))
// Check if same set of blocks
const sameBlocks =
prevBlockIds.size === incomingBlockIds.size &&
[...prevBlockIds].every((id) => incomingBlockIds.has(id))
if (sameBlocks) {
// Merge: keep earliest before, latest after for each block
const mergedMoves = incoming.data.moves.map((incomingMove) => {
const prevMove = prev.data.moves.find((m) => m.blockId === incomingMove.blockId)!
return {
blockId: incomingMove.blockId,
before: prevMove.before,
after: incomingMove.after,
}
})
// Check if all moves result in same position (net no-op)
const allSameAfter = mergedMoves.every((move) => {
const sameParent = (move.before.parentId ?? null) === (move.after.parentId ?? null)
return (
move.before.x === move.after.x && move.before.y === move.after.y && sameParent
)
})
const newUndoCoalesced: OperationEntry[] = allSameAfter
? stack.undo.slice(0, -1)
: (() => {
const op = entry.operation as BatchMoveBlocksOperation
const inv = entry.inverse as BatchMoveBlocksOperation
const newEntry: OperationEntry = {
id: entry.id,
createdAt: entry.createdAt,
operation: {
id: op.id,
type: 'batch-move-blocks',
timestamp: op.timestamp,
workflowId,
userId,
data: { moves: mergedMoves },
},
inverse: {
id: inv.id,
type: 'batch-move-blocks',
timestamp: inv.timestamp,
workflowId,
userId,
data: {
moves: mergedMoves.map((m) => ({
blockId: m.blockId,
before: m.after,
after: m.before,
})),
},
},
}
return [...stack.undo.slice(0, -1), newEntry]
})()
currentStacks[key] = {
undo: newUndoCoalesced,
redo: [],
lastUpdated: Date.now(),
}
set({ stacks: currentStacks })
logger.debug('Coalesced consecutive batch move operations', {
workflowId,
userId,
blockCount: mergedMoves.length,
undoSize: newUndoCoalesced.length,
})
return
}
}
}
const newUndo = [...stack.undo, entry]
if (newUndo.length > state.capacity) {
newUndo.shift()
}
currentStacks[key] = {
undo: newUndo,
redo: [],
lastUpdated: Date.now(),
}
set({ stacks: currentStacks })
logger.debug('Pushed operation to undo stack', {
workflowId,
userId,
operationType: entry.operation.type,
undoSize: newUndo.length,
})
},
undo: (workflowId: string, userId: string) => {
const key = getStackKey(workflowId, userId)
const state = get()
const stack = state.stacks[key]
if (!stack || stack.undo.length === 0) {
return null
}
const entry = stack.undo[stack.undo.length - 1]
const newUndo = stack.undo.slice(0, -1)
const newRedo = [...stack.redo, entry]
if (newRedo.length > state.capacity) {
newRedo.shift()
}
set({
stacks: {
...state.stacks,
[key]: {
undo: newUndo,
redo: newRedo,
lastUpdated: Date.now(),
},
},
})
logger.debug('Undo operation', {
workflowId,
userId,
operationType: entry.operation.type,
undoSize: newUndo.length,
redoSize: newRedo.length,
})
return entry
},
redo: (workflowId: string, userId: string) => {
const key = getStackKey(workflowId, userId)
const state = get()
const stack = state.stacks[key]
if (!stack || stack.redo.length === 0) {
return null
}
const entry = stack.redo[stack.redo.length - 1]
const newRedo = stack.redo.slice(0, -1)
const newUndo = [...stack.undo, entry]
if (newUndo.length > state.capacity) {
newUndo.shift()
}
set({
stacks: {
...state.stacks,
[key]: {
undo: newUndo,
redo: newRedo,
lastUpdated: Date.now(),
},
},
})
logger.debug('Redo operation', {
workflowId,
userId,
operationType: entry.operation.type,
undoSize: newUndo.length,
redoSize: newRedo.length,
})
return entry
},
clear: (workflowId: string, userId: string) => {
const key = getStackKey(workflowId, userId)
const state = get()
const { [key]: _, ...rest } = state.stacks
set({ stacks: rest })
logger.debug('Cleared undo/redo stacks', { workflowId, userId })
},
clearRedo: (workflowId: string, userId: string) => {
const key = getStackKey(workflowId, userId)
const state = get()
const stack = state.stacks[key]
if (!stack) return
set({
stacks: {
...state.stacks,
[key]: { ...stack, redo: [] },
},
})
logger.debug('Cleared redo stack', { workflowId, userId })
},
getStackSizes: (workflowId: string, userId: string) => {
const key = getStackKey(workflowId, userId)
const state = get()
const stack = state.stacks[key]
if (!stack) {
return { undoSize: 0, redoSize: 0 }
}
return {
undoSize: stack.undo.length,
redoSize: stack.redo.length,
}
},
setCapacity: (capacity: number) => {
const state = get()
const newStacks: typeof state.stacks = {}
for (const [key, stack] of Object.entries(state.stacks)) {
newStacks[key] = {
undo: stack.undo.slice(-capacity),
redo: stack.redo.slice(-capacity),
lastUpdated: stack.lastUpdated,
}
}
set({ capacity, stacks: newStacks })
logger.debug('Set capacity', { capacity })
},
pruneInvalidEntries: (
workflowId: string,
userId: string,
graph: { blocksById: Record<string, BlockState>; edgesById: Record<string, Edge> }
) => {
const key = getStackKey(workflowId, userId)
const state = get()
const stack = state.stacks[key]
if (!stack) return
const originalUndoCount = stack.undo.length
const originalRedoCount = stack.redo.length
const validUndo = stack.undo.filter((entry) => isOperationApplicable(entry.inverse, graph))
const validRedo = stack.redo.filter((entry) =>
isOperationApplicable(entry.operation, graph)
)
const prunedUndoCount = originalUndoCount - validUndo.length
const prunedRedoCount = originalRedoCount - validRedo.length
if (prunedUndoCount > 0 || prunedRedoCount > 0) {
set({
stacks: {
...state.stacks,
[key]: { ...stack, undo: validUndo, redo: validRedo },
},
})
logger.debug('Pruned invalid entries', {
workflowId,
userId,
prunedUndo: prunedUndoCount,
prunedRedo: prunedRedoCount,
remainingUndo: validUndo.length,
remainingRedo: validRedo.length,
})
}
},
}),
{
name: 'workflow-undo-redo',
storage: createJSONStorage(() => safeStorageAdapter),
partialize: (state) => ({
stacks: state.stacks,
capacity: state.capacity,
}),
}
)
)
+201
View File
@@ -0,0 +1,201 @@
import type { UNDO_REDO_OPERATIONS, UndoRedoOperation } from '@sim/realtime-protocol/constants'
import type { Edge } from 'reactflow'
import type { BlockState } from '@/stores/workflows/workflow/types'
export type OperationType = UndoRedoOperation
interface BaseOperation {
id: string
type: OperationType
timestamp: number
workflowId: string
userId: string
}
export interface BatchAddBlocksOperation extends BaseOperation {
type: typeof UNDO_REDO_OPERATIONS.BATCH_ADD_BLOCKS
data: {
blockSnapshots: BlockState[]
edgeSnapshots: Edge[]
subBlockValues: Record<string, Record<string, unknown>>
}
}
export interface BatchRemoveBlocksOperation extends BaseOperation {
type: typeof UNDO_REDO_OPERATIONS.BATCH_REMOVE_BLOCKS
data: {
blockSnapshots: BlockState[]
edgeSnapshots: Edge[]
subBlockValues: Record<string, Record<string, unknown>>
}
}
export interface BatchAddEdgesOperation extends BaseOperation {
type: typeof UNDO_REDO_OPERATIONS.BATCH_ADD_EDGES
data: {
edgeSnapshots: Edge[]
}
}
export interface BatchRemoveEdgesOperation extends BaseOperation {
type: typeof UNDO_REDO_OPERATIONS.BATCH_REMOVE_EDGES
data: {
edgeSnapshots: Edge[]
}
}
export interface BatchMoveBlocksOperation extends BaseOperation {
type: typeof UNDO_REDO_OPERATIONS.BATCH_MOVE_BLOCKS
data: {
moves: Array<{
blockId: string
before: { x: number; y: number; parentId?: string }
after: { x: number; y: number; parentId?: string }
}>
}
}
export interface UpdateParentOperation extends BaseOperation {
type: typeof UNDO_REDO_OPERATIONS.UPDATE_PARENT
data: {
blockId: string
oldParentId?: string
newParentId?: string
oldPosition: { x: number; y: number }
newPosition: { x: number; y: number }
affectedEdges?: Edge[]
}
}
export interface BatchUpdateParentOperation extends BaseOperation {
type: typeof UNDO_REDO_OPERATIONS.BATCH_UPDATE_PARENT
data: {
updates: Array<{
blockId: string
oldParentId?: string
newParentId?: string
oldPosition: { x: number; y: number }
newPosition: { x: number; y: number }
affectedEdges?: Edge[]
}>
}
}
export interface BatchToggleEnabledOperation extends BaseOperation {
type: typeof UNDO_REDO_OPERATIONS.BATCH_TOGGLE_ENABLED
data: {
blockIds: string[]
previousStates: Record<string, boolean>
}
}
export interface BatchToggleHandlesOperation extends BaseOperation {
type: typeof UNDO_REDO_OPERATIONS.BATCH_TOGGLE_HANDLES
data: {
blockIds: string[]
previousStates: Record<string, boolean>
}
}
export interface BatchToggleLockedOperation extends BaseOperation {
type: typeof UNDO_REDO_OPERATIONS.BATCH_TOGGLE_LOCKED
data: {
blockIds: string[]
previousStates: Record<string, boolean>
}
}
export interface BatchUpdateSubblocksOperation extends BaseOperation {
type: typeof UNDO_REDO_OPERATIONS.BATCH_UPDATE_SUBBLOCKS
data: {
updates: Array<{
blockId: string
subBlockId: string
before: unknown
after: unknown
}>
subflowUpdates?: Array<{
blockId: string
blockType: 'loop' | 'parallel'
fieldId: string
before: unknown
after: unknown
}>
}
}
interface ApplyDiffOperation extends BaseOperation {
type: typeof UNDO_REDO_OPERATIONS.APPLY_DIFF
data: {
baselineSnapshot: any // WorkflowState snapshot before diff
proposedState: any // WorkflowState with diff applied
diffAnalysis: any // DiffAnalysis for re-applying markers
}
}
interface AcceptDiffOperation extends BaseOperation {
type: typeof UNDO_REDO_OPERATIONS.ACCEPT_DIFF
data: {
beforeAccept: any // WorkflowState with diff markers
afterAccept: any // WorkflowState without diff markers
diffAnalysis: any // DiffAnalysis to restore markers on undo
baselineSnapshot: any // Baseline workflow state
}
}
interface RejectDiffOperation extends BaseOperation {
type: typeof UNDO_REDO_OPERATIONS.REJECT_DIFF
data: {
beforeReject: any // WorkflowState with diff markers
afterReject: any // WorkflowState baseline (after reject)
diffAnalysis: any // DiffAnalysis to restore markers on undo
baselineSnapshot: any // Baseline workflow state
}
}
export type Operation =
| BatchAddBlocksOperation
| BatchRemoveBlocksOperation
| BatchAddEdgesOperation
| BatchRemoveEdgesOperation
| BatchMoveBlocksOperation
| UpdateParentOperation
| BatchUpdateParentOperation
| BatchToggleEnabledOperation
| BatchToggleHandlesOperation
| BatchToggleLockedOperation
| BatchUpdateSubblocksOperation
| ApplyDiffOperation
| AcceptDiffOperation
| RejectDiffOperation
export interface OperationEntry {
id: string
operation: Operation
inverse: Operation
createdAt: number
}
export interface UndoRedoState {
stacks: Record<
string,
{
undo: OperationEntry[]
redo: OperationEntry[]
lastUpdated?: number
}
>
capacity: number
push: (workflowId: string, userId: string, entry: OperationEntry) => void
undo: (workflowId: string, userId: string) => OperationEntry | null
redo: (workflowId: string, userId: string) => OperationEntry | null
clear: (workflowId: string, userId: string) => void
clearRedo: (workflowId: string, userId: string) => void
getStackSizes: (workflowId: string, userId: string) => { undoSize: number; redoSize: number }
setCapacity: (capacity: number) => void
pruneInvalidEntries: (
workflowId: string,
userId: string,
graph: { blocksById: Record<string, BlockState>; edgesById: Record<string, Edge> }
) => void
}
+449
View File
@@ -0,0 +1,449 @@
/**
* @vitest-environment node
*/
import type { Edge } from 'reactflow'
import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest'
import type { BlockState } from '@/stores/workflows/workflow/types'
vi.mock('@/stores/workflows/utils', () => ({
mergeSubblockState: vi.fn(),
}))
import { UNDO_REDO_OPERATIONS } from '@sim/realtime-protocol/constants'
import { mergeSubblockState } from '@/stores/workflows/utils'
import { captureLatestEdges, captureLatestSubBlockValues, createInverseOperation } from './utils'
const mockMergeSubblockState = mergeSubblockState as Mock
describe('captureLatestEdges', () => {
const createEdge = (id: string, source: string, target: string): Edge => ({
id,
source,
target,
})
it('should return edges where blockId is the source', () => {
const edges = [
createEdge('edge-1', 'block-1', 'block-2'),
createEdge('edge-2', 'block-3', 'block-4'),
]
const result = captureLatestEdges(edges, ['block-1'])
expect(result).toEqual([createEdge('edge-1', 'block-1', 'block-2')])
})
it('should return edges where blockId is the target', () => {
const edges = [
createEdge('edge-1', 'block-1', 'block-2'),
createEdge('edge-2', 'block-3', 'block-4'),
]
const result = captureLatestEdges(edges, ['block-2'])
expect(result).toEqual([createEdge('edge-1', 'block-1', 'block-2')])
})
it('should return edges for multiple blocks', () => {
const edges = [
createEdge('edge-1', 'block-1', 'block-2'),
createEdge('edge-2', 'block-3', 'block-4'),
createEdge('edge-3', 'block-2', 'block-5'),
]
const result = captureLatestEdges(edges, ['block-1', 'block-2'])
expect(result).toHaveLength(2)
expect(result).toContainEqual(createEdge('edge-1', 'block-1', 'block-2'))
expect(result).toContainEqual(createEdge('edge-3', 'block-2', 'block-5'))
})
it('should return empty array when no edges match', () => {
const edges = [
createEdge('edge-1', 'block-1', 'block-2'),
createEdge('edge-2', 'block-3', 'block-4'),
]
const result = captureLatestEdges(edges, ['block-99'])
expect(result).toEqual([])
})
it('should return empty array when blockIds is empty', () => {
const edges = [
createEdge('edge-1', 'block-1', 'block-2'),
createEdge('edge-2', 'block-3', 'block-4'),
]
const result = captureLatestEdges(edges, [])
expect(result).toEqual([])
})
it('should return edge when block has both source and target edges', () => {
const edges = [
createEdge('edge-1', 'block-1', 'block-2'),
createEdge('edge-2', 'block-2', 'block-3'),
createEdge('edge-3', 'block-4', 'block-2'),
]
const result = captureLatestEdges(edges, ['block-2'])
expect(result).toHaveLength(3)
expect(result).toContainEqual(createEdge('edge-1', 'block-1', 'block-2'))
expect(result).toContainEqual(createEdge('edge-2', 'block-2', 'block-3'))
expect(result).toContainEqual(createEdge('edge-3', 'block-4', 'block-2'))
})
it('should handle empty edges array', () => {
const result = captureLatestEdges([], ['block-1'])
expect(result).toEqual([])
})
it('should not duplicate edges when block appears in multiple blockIds', () => {
const edges = [createEdge('edge-1', 'block-1', 'block-2')]
const result = captureLatestEdges(edges, ['block-1', 'block-2'])
expect(result).toHaveLength(1)
expect(result).toContainEqual(createEdge('edge-1', 'block-1', 'block-2'))
})
})
describe('captureLatestSubBlockValues', () => {
const workflowId = 'wf-test'
const createBlockState = (
id: string,
subBlocks: Record<string, { id: string; type: string; value: unknown }>
): BlockState =>
({
id,
type: 'function',
name: 'Test Block',
position: { x: 0, y: 0 },
subBlocks: Object.fromEntries(
Object.entries(subBlocks).map(([subId, sb]) => [
subId,
{ id: sb.id, type: sb.type, value: sb.value },
])
),
outputs: {},
enabled: true,
}) as BlockState
beforeEach(() => {
vi.clearAllMocks()
})
it('should capture single block with single subblock value', () => {
const blocks: Record<string, BlockState> = {
'block-1': createBlockState('block-1', {
code: { id: 'code', type: 'code', value: 'console.log("hello")' },
}),
}
mockMergeSubblockState.mockReturnValue(blocks)
const result = captureLatestSubBlockValues(blocks, workflowId, ['block-1'])
expect(result).toEqual({
'block-1': { code: 'console.log("hello")' },
})
})
it('should capture single block with multiple subblock values', () => {
const blocks: Record<string, BlockState> = {
'block-1': createBlockState('block-1', {
code: { id: 'code', type: 'code', value: 'test code' },
model: { id: 'model', type: 'dropdown', value: 'gpt-4' },
temperature: { id: 'temperature', type: 'slider', value: 0.7 },
}),
}
mockMergeSubblockState.mockReturnValue(blocks)
const result = captureLatestSubBlockValues(blocks, workflowId, ['block-1'])
expect(result).toEqual({
'block-1': {
code: 'test code',
model: 'gpt-4',
temperature: 0.7,
},
})
})
it('should capture multiple blocks with values', () => {
const blocks: Record<string, BlockState> = {
'block-1': createBlockState('block-1', {
code: { id: 'code', type: 'code', value: 'code 1' },
}),
'block-2': createBlockState('block-2', {
prompt: { id: 'prompt', type: 'long-input', value: 'hello world' },
}),
}
mockMergeSubblockState.mockImplementation((_blocks, _wfId, blockId) => {
if (blockId === 'block-1') return { 'block-1': blocks['block-1'] }
if (blockId === 'block-2') return { 'block-2': blocks['block-2'] }
return {}
})
const result = captureLatestSubBlockValues(blocks, workflowId, ['block-1', 'block-2'])
expect(result).toEqual({
'block-1': { code: 'code 1' },
'block-2': { prompt: 'hello world' },
})
})
it('should skip null values', () => {
const blocks: Record<string, BlockState> = {
'block-1': createBlockState('block-1', {
code: { id: 'code', type: 'code', value: 'valid code' },
empty: { id: 'empty', type: 'short-input', value: null },
}),
}
mockMergeSubblockState.mockReturnValue(blocks)
const result = captureLatestSubBlockValues(blocks, workflowId, ['block-1'])
expect(result).toEqual({
'block-1': { code: 'valid code' },
})
expect(result['block-1']).not.toHaveProperty('empty')
})
it('should skip undefined values', () => {
const blocks: Record<string, BlockState> = {
'block-1': createBlockState('block-1', {
code: { id: 'code', type: 'code', value: 'valid code' },
empty: { id: 'empty', type: 'short-input', value: undefined },
}),
}
mockMergeSubblockState.mockReturnValue(blocks)
const result = captureLatestSubBlockValues(blocks, workflowId, ['block-1'])
expect(result).toEqual({
'block-1': { code: 'valid code' },
})
})
it('should return empty object for block with no subBlocks', () => {
const blocks: Record<string, BlockState> = {
'block-1': {
id: 'block-1',
type: 'function',
name: 'Test Block',
position: { x: 0, y: 0 },
subBlocks: {},
outputs: {},
enabled: true,
} as BlockState,
}
mockMergeSubblockState.mockReturnValue(blocks)
const result = captureLatestSubBlockValues(blocks, workflowId, ['block-1'])
expect(result).toEqual({})
})
it('should return empty object for non-existent blockId', () => {
const blocks: Record<string, BlockState> = {
'block-1': createBlockState('block-1', {
code: { id: 'code', type: 'code', value: 'test' },
}),
}
mockMergeSubblockState.mockReturnValue({})
const result = captureLatestSubBlockValues(blocks, workflowId, ['non-existent'])
expect(result).toEqual({})
})
it('should return empty object when blockIds is empty', () => {
const blocks: Record<string, BlockState> = {
'block-1': createBlockState('block-1', {
code: { id: 'code', type: 'code', value: 'test' },
}),
}
const result = captureLatestSubBlockValues(blocks, workflowId, [])
expect(result).toEqual({})
expect(mockMergeSubblockState).not.toHaveBeenCalled()
})
it('should handle various value types (string, number, array)', () => {
const blocks: Record<string, BlockState> = {
'block-1': createBlockState('block-1', {
text: { id: 'text', type: 'short-input', value: 'string value' },
number: { id: 'number', type: 'slider', value: 42 },
array: {
id: 'array',
type: 'table',
value: [
['a', 'b'],
['c', 'd'],
],
},
}),
}
mockMergeSubblockState.mockReturnValue(blocks)
const result = captureLatestSubBlockValues(blocks, workflowId, ['block-1'])
expect(result).toEqual({
'block-1': {
text: 'string value',
number: 42,
array: [
['a', 'b'],
['c', 'd'],
],
},
})
})
it('should only capture values for blockIds in the list', () => {
const blocks: Record<string, BlockState> = {
'block-1': createBlockState('block-1', {
code: { id: 'code', type: 'code', value: 'code 1' },
}),
'block-2': createBlockState('block-2', {
code: { id: 'code', type: 'code', value: 'code 2' },
}),
'block-3': createBlockState('block-3', {
code: { id: 'code', type: 'code', value: 'code 3' },
}),
}
mockMergeSubblockState.mockImplementation((_blocks, _wfId, blockId) => {
if (blockId === 'block-1') return { 'block-1': blocks['block-1'] }
if (blockId === 'block-3') return { 'block-3': blocks['block-3'] }
return {}
})
const result = captureLatestSubBlockValues(blocks, workflowId, ['block-1', 'block-3'])
expect(result).toEqual({
'block-1': { code: 'code 1' },
'block-3': { code: 'code 3' },
})
expect(result).not.toHaveProperty('block-2')
})
it('should handle block without subBlocks property', () => {
const blocks: Record<string, BlockState> = {
'block-1': {
id: 'block-1',
type: 'function',
name: 'Test Block',
position: { x: 0, y: 0 },
outputs: {},
enabled: true,
} as BlockState,
}
mockMergeSubblockState.mockReturnValue(blocks)
const result = captureLatestSubBlockValues(blocks, workflowId, ['block-1'])
expect(result).toEqual({})
})
it('should handle empty string values', () => {
const blocks: Record<string, BlockState> = {
'block-1': createBlockState('block-1', {
code: { id: 'code', type: 'code', value: '' },
}),
}
mockMergeSubblockState.mockReturnValue(blocks)
const result = captureLatestSubBlockValues(blocks, workflowId, ['block-1'])
expect(result).toEqual({
'block-1': { code: '' },
})
})
it('should handle zero numeric values', () => {
const blocks: Record<string, BlockState> = {
'block-1': createBlockState('block-1', {
temperature: { id: 'temperature', type: 'slider', value: 0 },
}),
}
mockMergeSubblockState.mockReturnValue(blocks)
const result = captureLatestSubBlockValues(blocks, workflowId, ['block-1'])
expect(result).toEqual({
'block-1': { temperature: 0 },
})
})
})
describe('createInverseOperation', () => {
it('inverts batch subblock updates', () => {
const operation = {
id: 'op-1',
type: UNDO_REDO_OPERATIONS.BATCH_UPDATE_SUBBLOCKS,
timestamp: 1,
workflowId: 'workflow-1',
userId: 'user-1',
data: {
updates: [
{
blockId: 'block-1',
subBlockId: 'prompt',
before: 'old',
after: 'new',
},
],
subflowUpdates: [
{
blockId: 'loop-1',
blockType: 'loop',
fieldId: 'subflowIterations',
before: 2,
after: 3,
},
],
},
}
expect(createInverseOperation(operation)).toEqual({
...operation,
data: {
updates: [
{
blockId: 'block-1',
subBlockId: 'prompt',
before: 'new',
after: 'old',
},
],
subflowUpdates: [
{
blockId: 'loop-1',
blockType: 'loop',
fieldId: 'subflowIterations',
before: 3,
after: 2,
},
],
},
})
})
})
+236
View File
@@ -0,0 +1,236 @@
import { UNDO_REDO_OPERATIONS } from '@sim/realtime-protocol/constants'
import { generateId } from '@sim/utils/id'
import type { Edge } from 'reactflow'
import type {
BatchAddBlocksOperation,
BatchAddEdgesOperation,
BatchMoveBlocksOperation,
BatchRemoveBlocksOperation,
BatchRemoveEdgesOperation,
BatchUpdateParentOperation,
BatchUpdateSubblocksOperation,
Operation,
OperationEntry,
} from '@/stores/undo-redo/types'
import { mergeSubblockState } from '@/stores/workflows/utils'
import type { BlockState } from '@/stores/workflows/workflow/types'
export function createOperationEntry(operation: Operation, inverse: Operation): OperationEntry {
return {
id: generateId(),
operation,
inverse,
createdAt: Date.now(),
}
}
export function createInverseOperation(operation: Operation): Operation {
switch (operation.type) {
case UNDO_REDO_OPERATIONS.BATCH_ADD_BLOCKS: {
const op = operation as BatchAddBlocksOperation
return {
...operation,
type: UNDO_REDO_OPERATIONS.BATCH_REMOVE_BLOCKS,
data: {
blockSnapshots: op.data.blockSnapshots,
edgeSnapshots: op.data.edgeSnapshots,
subBlockValues: op.data.subBlockValues,
},
} as BatchRemoveBlocksOperation
}
case UNDO_REDO_OPERATIONS.BATCH_REMOVE_BLOCKS: {
const op = operation as BatchRemoveBlocksOperation
return {
...operation,
type: UNDO_REDO_OPERATIONS.BATCH_ADD_BLOCKS,
data: {
blockSnapshots: op.data.blockSnapshots,
edgeSnapshots: op.data.edgeSnapshots,
subBlockValues: op.data.subBlockValues,
},
} as BatchAddBlocksOperation
}
case UNDO_REDO_OPERATIONS.BATCH_ADD_EDGES: {
const op = operation as BatchAddEdgesOperation
return {
...operation,
type: UNDO_REDO_OPERATIONS.BATCH_REMOVE_EDGES,
data: {
edgeSnapshots: op.data.edgeSnapshots,
},
} as BatchRemoveEdgesOperation
}
case UNDO_REDO_OPERATIONS.BATCH_REMOVE_EDGES: {
const op = operation as BatchRemoveEdgesOperation
return {
...operation,
type: UNDO_REDO_OPERATIONS.BATCH_ADD_EDGES,
data: {
edgeSnapshots: op.data.edgeSnapshots,
},
} as BatchAddEdgesOperation
}
case UNDO_REDO_OPERATIONS.BATCH_MOVE_BLOCKS: {
const op = operation as BatchMoveBlocksOperation
return {
...operation,
type: UNDO_REDO_OPERATIONS.BATCH_MOVE_BLOCKS,
data: {
moves: op.data.moves.map((m) => ({
blockId: m.blockId,
before: m.after,
after: m.before,
})),
},
} as BatchMoveBlocksOperation
}
case UNDO_REDO_OPERATIONS.UPDATE_PARENT:
return {
...operation,
data: {
blockId: operation.data.blockId,
oldParentId: operation.data.newParentId,
newParentId: operation.data.oldParentId,
oldPosition: operation.data.newPosition,
newPosition: operation.data.oldPosition,
affectedEdges: operation.data.affectedEdges,
},
}
case UNDO_REDO_OPERATIONS.BATCH_UPDATE_PARENT: {
const op = operation as BatchUpdateParentOperation
return {
...operation,
data: {
updates: op.data.updates.map((u) => ({
blockId: u.blockId,
oldParentId: u.newParentId,
newParentId: u.oldParentId,
oldPosition: u.newPosition,
newPosition: u.oldPosition,
affectedEdges: u.affectedEdges,
})),
},
} as BatchUpdateParentOperation
}
case UNDO_REDO_OPERATIONS.APPLY_DIFF:
return {
...operation,
data: {
baselineSnapshot: operation.data.proposedState,
proposedState: operation.data.baselineSnapshot,
diffAnalysis: operation.data.diffAnalysis,
},
}
case UNDO_REDO_OPERATIONS.ACCEPT_DIFF:
return {
...operation,
data: {
beforeAccept: operation.data.afterAccept,
afterAccept: operation.data.beforeAccept,
diffAnalysis: operation.data.diffAnalysis,
baselineSnapshot: operation.data.baselineSnapshot,
},
}
case UNDO_REDO_OPERATIONS.REJECT_DIFF:
return {
...operation,
data: {
beforeReject: operation.data.afterReject,
afterReject: operation.data.beforeReject,
diffAnalysis: operation.data.diffAnalysis,
baselineSnapshot: operation.data.baselineSnapshot,
},
}
case UNDO_REDO_OPERATIONS.BATCH_TOGGLE_ENABLED:
return {
...operation,
data: {
blockIds: operation.data.blockIds,
previousStates: operation.data.previousStates,
},
}
case UNDO_REDO_OPERATIONS.BATCH_TOGGLE_HANDLES:
return {
...operation,
data: {
blockIds: operation.data.blockIds,
previousStates: operation.data.previousStates,
},
}
case UNDO_REDO_OPERATIONS.BATCH_TOGGLE_LOCKED:
return {
...operation,
data: {
blockIds: operation.data.blockIds,
previousStates: operation.data.previousStates,
},
}
case UNDO_REDO_OPERATIONS.BATCH_UPDATE_SUBBLOCKS: {
const op = operation as BatchUpdateSubblocksOperation
return {
...operation,
data: {
updates: op.data.updates.map((update) => ({
blockId: update.blockId,
subBlockId: update.subBlockId,
before: update.after,
after: update.before,
})),
subflowUpdates: op.data.subflowUpdates?.map((update) => ({
blockId: update.blockId,
blockType: update.blockType,
fieldId: update.fieldId,
before: update.after,
after: update.before,
})),
},
} as BatchUpdateSubblocksOperation
}
default: {
const exhaustiveCheck: never = operation
throw new Error(`Unhandled operation type: ${(exhaustiveCheck as Operation).type}`)
}
}
}
export function captureLatestEdges(edges: Edge[], blockIds: string[]): Edge[] {
return edges.filter((e) => blockIds.includes(e.source) || blockIds.includes(e.target))
}
export function captureLatestSubBlockValues(
blocks: Record<string, BlockState>,
workflowId: string,
blockIds: string[]
): Record<string, Record<string, unknown>> {
const values: Record<string, Record<string, unknown>> = {}
blockIds.forEach((blockId) => {
const merged = mergeSubblockState(blocks, workflowId, blockId)
const block = merged[blockId]
if (block?.subBlocks) {
const blockValues: Record<string, unknown> = {}
Object.entries(block.subBlocks).forEach(([subBlockId, subBlock]) => {
if (subBlock.value !== null && subBlock.value !== undefined) {
blockValues[subBlockId] = subBlock.value
}
})
if (Object.keys(blockValues).length > 0) {
values[blockId] = blockValues
}
}
})
return values
}