fix(editor): Gate autosave on document hydration (#36966)

This commit is contained in:
Daria
2026-08-26 07:31:22 +00:00
committed by GitHub
parent 31b006a591
commit c0bc17d680
8 changed files with 428 additions and 43 deletions
@@ -4665,6 +4665,29 @@ describe('useCanvasOperations', () => {
expect(workflowDocumentStoreInstance.setConnections).toHaveBeenCalled();
});
it('marks the document hydrated after nodes and connections are set', async () => {
const workflow = createTestWorkflow({
id: workflowId,
nodes: [createTestNode()],
connections: {},
});
const setNodesSpy = vi.spyOn(workflowDocumentStoreInstance, 'setNodes');
const setConnectionsSpy = vi.spyOn(workflowDocumentStoreInstance, 'setConnections');
const setHydratedSpy = vi.spyOn(workflowDocumentStoreInstance, 'setHydrated');
const { initializeWorkspace } = useCanvasOperations();
await initializeWorkspace(workflow);
expect(setHydratedSpy).toHaveBeenCalledWith(true);
expect(setHydratedSpy.mock.invocationCallOrder[0]).toBeGreaterThan(
setNodesSpy.mock.invocationCallOrder[0],
);
expect(setHydratedSpy.mock.invocationCallOrder[0]).toBeGreaterThan(
setConnectionsSpy.mock.invocationCallOrder[0],
);
});
it('should set connections even when workflowId is initially empty', async () => {
const workflowsStore = useWorkflowsStore();
// Simulate the state after resetWorkspace() — workflowId is cleared
@@ -2712,6 +2712,7 @@ export function useCanvasOperations() {
initializedDocumentStore.setNodes(nodes);
initializedDocumentStore.setConnections(connections);
initializedDocumentStore.setHydrated(true);
return { workflowDocumentStore: initializedDocumentStore };
}
@@ -3530,6 +3531,7 @@ export function useCanvasOperations() {
projectsStore.currentProjectId,
);
workflowDocumentStore.value.setName(workflowData.name);
workflowDocumentStore.value.setHydrated(true);
}
async function tryToOpenSubworkflowInNewTab(nodeId: string): Promise<boolean> {
@@ -107,6 +107,7 @@ const mockWorkflowDocumentStore = vi.hoisted(() => ({
setHomeProject: vi.fn(),
setScopes: vi.fn(),
setParentFolder: vi.fn(),
setHydrated: vi.fn(),
onNameChange: vi.fn(),
}));
vi.mock('@/app/stores/workflowDocument.store', () => ({
@@ -200,5 +201,16 @@ describe('useWorkflowInitialization', () => {
expect(mockSetDocumentTitle).toHaveBeenCalledWith('New Workflow', 'IDLE');
});
it('marks a fresh workflow document hydrated after initialization', async () => {
let initializeWorkspaceForNewWorkflow!: () => Promise<void>;
renderWithComposable((init) => {
initializeWorkspaceForNewWorkflow = init.initializeWorkspaceForNewWorkflow;
});
await initializeWorkspaceForNewWorkflow();
expect(mockWorkflowDocumentStore.setHydrated).toHaveBeenCalledWith(true);
});
});
});
@@ -334,6 +334,7 @@ export function useWorkflowInitialization() {
const parentFolder = await fetchParentFolder(parentFolderId);
currentWorkflowDocumentStore.value?.setParentFolder(parentFolder);
currentWorkflowDocumentStore.value.setHydrated(true);
uiStore.nodeViewInitialized = true;
initializedWorkflowId.value = workflowId.value;
@@ -153,6 +153,23 @@ describe('useWorkflowSaving', () => {
backendConnectionStore.setOnline(true);
});
function prepareHydratedWorkflow(workflowId: string) {
const workflow = createTestWorkflow({
id: workflowId,
nodes: [createTestNode({ type: CHAT_TRIGGER_NODE_TYPE, disabled: false })],
});
mockRoute.params = { workflowId };
workflowsStore.setWorkflowId(workflowId);
workflowsListStore.workflowsById = {
...workflowsListStore.workflowsById,
[workflowId]: workflow,
};
const documentStore = useWorkflowDocumentStore(createWorkflowDocumentId(workflowId));
documentStore.hydrate(workflow);
return { workflow, documentStore };
}
describe('promptSaveUnsavedWorkflowChanges', () => {
it('should prompt the user to save changes and proceed if confirmed', async () => {
const workflow = createTestWorkflow({
@@ -239,6 +256,43 @@ describe('useWorkflowSaving', () => {
expect(next).toHaveBeenCalledWith();
});
it('cancels a scheduled autosave when the user discards changes', async () => {
vi.useFakeTimers();
try {
const { workflow } = prepareHydratedWorkflow('w-discard-cancel-autosave');
const updateSpy = vi
.spyOn(workflowsStore, 'updateWorkflow')
.mockResolvedValue({ ...workflow, checksum: 'test-checksum' });
const next = vi.fn();
const confirm = vi.fn();
const cancel = vi.fn();
const uiStore = useUIStore();
const saveStore = useWorkflowSaveStore();
uiStore.markStateDirty();
modalConfirmSpy.mockResolvedValue(MODAL_CANCEL);
const workflowSaving = useWorkflowSaving({ router, ownsAutoSave: true });
workflowSaving.autoSaveWorkflow();
expect(saveStore.autoSaveState).toBe(AutoSaveState.Scheduled);
await workflowSaving.promptSaveUnsavedWorkflowChanges(next, { confirm, cancel });
expect(saveStore.autoSaveState).toBe(AutoSaveState.Idle);
expect(uiStore.stateIsDirty).toBe(false);
await vi.advanceTimersByTimeAsync(
getDebounceTime(DEBOUNCE_TIME.API.AUTOSAVE_MAX_WAIT) + 1000,
);
expect(updateSpy).not.toHaveBeenCalled();
expect(next).toHaveBeenCalledWith();
} finally {
vi.useRealTimers();
}
});
it('should restore the route if the modal is closed and the workflow is not new', async () => {
const next = vi.fn();
const confirm = vi.fn();
@@ -598,6 +652,38 @@ describe('useWorkflowSaving', () => {
);
});
it('allows a manual save before the document is marked hydrated', async () => {
const workflow = createTestWorkflow({
id: 'w-manual-unhydrated',
name: 'Manual unhydrated workflow',
nodes: [createTestNode({ type: CHAT_TRIGGER_NODE_TYPE, disabled: false })],
});
vi.spyOn(workflowsStore, 'updateWorkflow').mockResolvedValue({
...workflow,
checksum: 'test-checksum',
});
workflowsStore.setWorkflowId(workflow.id);
workflowsListStore.workflowsById = { [workflow.id]: workflow };
const documentStore = useWorkflowDocumentStore(createWorkflowDocumentId(workflow.id));
documentStore.setName(workflow.name);
documentStore.setNodes(workflow.nodes);
documentStore.setConnections(workflow.connections);
expect(documentStore.hydrated).toBe(false);
const { saveCurrentWorkflow } = useWorkflowSaving({ router });
const saved = await saveCurrentWorkflow({ id: workflow.id }, true, false, false);
expect(saved).toBe(true);
expect(workflowsStore.updateWorkflow).toHaveBeenCalledWith(
workflow.id,
expect.objectContaining({ id: workflow.id, name: workflow.name }),
false,
);
});
it('should not include active=false in the request if the workflow has no activatable trigger node', async () => {
const workflow = createTestWorkflow({
id: 'w1',
@@ -808,6 +894,7 @@ describe('useWorkflowSaving', () => {
describe('autoSaveWorkflow', () => {
it('should not schedule autosave if a save is already in progress', () => {
prepareHydratedWorkflow('w-pending-save');
const saveStore = useWorkflowSaveStore();
// Simulate an ongoing save by setting pendingSave
@@ -824,6 +911,7 @@ describe('useWorkflowSaving', () => {
});
it('should schedule autosave when state is Idle', () => {
prepareHydratedWorkflow('w-schedule');
const saveStore = useWorkflowSaveStore();
// Ensure state is Idle
@@ -929,6 +1017,7 @@ describe('useWorkflowSaving', () => {
});
workflowsStore.setWorkflowId(workflow.id);
mockRoute.params = { workflowId: workflow.id };
useWorkflowDocumentStore(createWorkflowDocumentId(workflow.id)).hydrate(workflow);
workflowsListStore.workflowsById = { [workflow.id]: workflow };
@@ -1332,6 +1421,7 @@ describe('useWorkflowSaving', () => {
});
it('should not schedule autosave when network is offline', () => {
prepareHydratedWorkflow('w-offline');
const saveStore = useWorkflowSaveStore();
backendConnectionStore.setOnline(false);
@@ -1345,6 +1435,7 @@ describe('useWorkflowSaving', () => {
});
it('should not schedule autosave when autosave is disabled via environment variable', () => {
prepareHydratedWorkflow('w-disabled');
const autosaveStore = useWorkflowSaveStore();
const settingsStore = mockedStore(useSettingsStore);
@@ -1364,6 +1455,7 @@ describe('useWorkflowSaving', () => {
});
it('should schedule autosave when autosave is enabled via environment variable', () => {
prepareHydratedWorkflow('w-enabled');
const autosaveStore = useWorkflowSaveStore();
const settingsStore = mockedStore(useSettingsStore);
@@ -1383,6 +1475,233 @@ describe('useWorkflowSaving', () => {
});
});
describe('autosave document hydration gate', () => {
const EDITABLE_FEATURES: EditorEnabledFeatures = {
readOnly: false,
expandGroups: 'all',
aiAssistant: false,
aiBuilder: false,
askAi: false,
executionSuccessToasts: false,
executionErrorToasts: false,
};
const probe: { current: ReturnType<typeof useWorkflowSaving> | null } = { current: null };
const AutosaveProbe = defineComponent({
name: 'AutosaveHydrationProbe',
setup() {
probe.current = useWorkflowSaving({ router, ownsAutoSave: true });
return () => h('div');
},
});
const AutosaveHostStub = defineComponent({
name: 'AutosaveHydrationHostStub',
props: {
workflowId: { type: String, required: true },
},
setup(props) {
provide(
WorkflowIdKey,
computed(() => props.workflowId),
);
provide(
EditorEnabledFeaturesKey,
computed<EditorEnabledFeatures>(() => EDITABLE_FEATURES),
);
return () => h(AutosaveProbe);
},
});
function takeProbe(): ReturnType<typeof useWorkflowSaving> {
const saving = probe.current;
if (!saving) throw new Error('AutosaveHydrationProbe did not initialise');
return saving;
}
function mountAutosaveHost(workflowId: string) {
probe.current = null;
const wrapper = mount(AutosaveHostStub, { props: { workflowId } });
return { wrapper, saving: takeProbe() };
}
async function flushAutoSave() {
await vi.advanceTimersByTimeAsync(
getDebounceTime(DEBOUNCE_TIME.API.AUTOSAVE_MAX_WAIT) + 1000,
);
}
beforeEach(() => {
vi.useFakeTimers();
mockedStore(useSettingsStore).isAutosaveEnabled = true;
useWorkflowSaveStore().reset();
useUIStore().markStateDirty();
});
afterEach(() => {
vi.useRealTimers();
probe.current = null;
});
it('defers scheduling while the current workflow document is not hydrated', async () => {
const workflow = createTestWorkflow({
id: 'w-hydration-pending',
nodes: [createTestNode({ type: CHAT_TRIGGER_NODE_TYPE, disabled: false })],
});
workflowsListStore.workflowsById = { [workflow.id]: workflow };
const updateSpy = vi
.spyOn(workflowsStore, 'updateWorkflow')
.mockResolvedValue({ ...workflow, checksum: 'test-checksum' });
const createSpy = vi
.spyOn(workflowsStore, 'createNewWorkflow')
.mockResolvedValue(createTestWorkflow({ id: 'created' }));
const saveStore = useWorkflowSaveStore();
const uiStore = useUIStore();
const { saving } = mountAutosaveHost(workflow.id);
saving.autoSaveWorkflow();
await flushAutoSave();
expect(saveStore.autoSaveState).toBe(AutoSaveState.Idle);
expect(saveStore.retryCount).toBe(0);
expect(uiStore.stateIsDirty).toBe(true);
expect(updateSpy).not.toHaveBeenCalled();
expect(createSpy).not.toHaveBeenCalled();
});
it('drops a scheduled autosave if navigation lands on an unhydrated document before it fires', async () => {
const workflow = createTestWorkflow({
id: 'w-hydrated-first',
nodes: [createTestNode({ type: CHAT_TRIGGER_NODE_TYPE, disabled: false })],
});
workflowsListStore.workflowsById = { [workflow.id]: workflow };
useWorkflowDocumentStore(createWorkflowDocumentId(workflow.id)).hydrate(workflow);
const updateSpy = vi
.spyOn(workflowsStore, 'updateWorkflow')
.mockResolvedValue({ ...workflow, checksum: 'test-checksum' });
const createSpy = vi
.spyOn(workflowsStore, 'createNewWorkflow')
.mockResolvedValue(createTestWorkflow({ id: 'created' }));
const saveStore = useWorkflowSaveStore();
const { wrapper, saving } = mountAutosaveHost(workflow.id);
saving.autoSaveWorkflow();
expect(saveStore.autoSaveState).toBe(AutoSaveState.Scheduled);
await wrapper.setProps({ workflowId: 'w-unhydrated-next' });
await flushAutoSave();
expect(saveStore.autoSaveState).toBe(AutoSaveState.Idle);
expect(saveStore.retryCount).toBe(0);
expect(useUIStore().stateIsDirty).toBe(true);
expect(updateSpy).not.toHaveBeenCalled();
expect(createSpy).not.toHaveBeenCalled();
});
it('drops a scheduled autosave if state becomes clean before it fires', async () => {
const workflow = createTestWorkflow({
id: 'w-clean-before-fire',
nodes: [createTestNode({ type: CHAT_TRIGGER_NODE_TYPE, disabled: false })],
});
workflowsListStore.workflowsById = { [workflow.id]: workflow };
useWorkflowDocumentStore(createWorkflowDocumentId(workflow.id)).hydrate(workflow);
const updateSpy = vi
.spyOn(workflowsStore, 'updateWorkflow')
.mockResolvedValue({ ...workflow, checksum: 'test-checksum' });
const createSpy = vi
.spyOn(workflowsStore, 'createNewWorkflow')
.mockResolvedValue(createTestWorkflow({ id: 'created' }));
const saveStore = useWorkflowSaveStore();
const uiStore = useUIStore();
const { saving } = mountAutosaveHost(workflow.id);
saving.autoSaveWorkflow();
expect(saveStore.autoSaveState).toBe(AutoSaveState.Scheduled);
uiStore.markStateClean();
await flushAutoSave();
expect(saveStore.autoSaveState).toBe(AutoSaveState.Idle);
expect(saveStore.retryCount).toBe(0);
expect(uiStore.stateIsDirty).toBe(false);
expect(updateSpy).not.toHaveBeenCalled();
expect(createSpy).not.toHaveBeenCalled();
});
it('re-arms autosave when an existing workflow document becomes hydrated while dirty', async () => {
const workflow = createTestWorkflow({
id: 'w-hydration-rearm',
name: 'Hydrated workflow',
nodes: [createTestNode({ type: CHAT_TRIGGER_NODE_TYPE, disabled: false })],
});
workflowsListStore.workflowsById = { [workflow.id]: workflow };
const updateSpy = vi
.spyOn(workflowsStore, 'updateWorkflow')
.mockResolvedValue({ ...workflow, checksum: 'test-checksum' });
const documentStore = useWorkflowDocumentStore(createWorkflowDocumentId(workflow.id));
const saveStore = useWorkflowSaveStore();
const { saving } = mountAutosaveHost(workflow.id);
saving.autoSaveWorkflow();
expect(saveStore.autoSaveState).toBe(AutoSaveState.Idle);
documentStore.hydrate(workflow);
await nextTick();
expect(saveStore.autoSaveState).toBe(AutoSaveState.Scheduled);
await flushAutoSave();
expect(updateSpy).toHaveBeenCalledWith(
workflow.id,
expect.objectContaining({
name: workflow.name,
nodes: expect.arrayContaining([
expect.objectContaining({ name: workflow.nodes[0].name }),
]),
}),
false,
);
});
it('creates a new workflow by autosave only after the new document is hydrated', async () => {
const newWorkflowId = 'w-new-hydration';
const createdWorkflow = createTestWorkflow({
id: 'w-created-from-autosave',
name: 'Named new workflow',
});
const createSpy = vi
.spyOn(workflowsStore, 'createNewWorkflow')
.mockResolvedValue(createdWorkflow);
const documentStore = useWorkflowDocumentStore(createWorkflowDocumentId(newWorkflowId));
const saveStore = useWorkflowSaveStore();
const { saving } = mountAutosaveHost(newWorkflowId);
saving.autoSaveWorkflow();
await flushAutoSave();
expect(createSpy).not.toHaveBeenCalled();
documentStore.setName('Named new workflow');
documentStore.setHydrated(true);
await nextTick();
expect(saveStore.autoSaveState).toBe(AutoSaveState.Scheduled);
await flushAutoSave();
expect(createSpy).toHaveBeenCalledWith(
expect.objectContaining({ name: 'Named new workflow', autosaved: true }),
);
});
});
describe('autosave on a read-only preview canvas', () => {
// Preview hosts (template, workflow history, execution) mount the real
// NodeView and supersede the editor context with `readOnly: true`. Opening a
@@ -1541,9 +1860,11 @@ describe('useWorkflowSaving', () => {
// Instance AI locks the canvas while its agent edits and then releases
// it; without this the agent's changes would sit unsaved.
const saveStore = useWorkflowSaveStore();
const workflow = createTestWorkflow({ id: 'w-rearm' });
useWorkflowDocumentStore(createWorkflowDocumentId(workflow.id)).hydrate(workflow);
const wrapper = mount(PreviewHostStub, {
props: { workflowId: 'w-rearm', readOnly: true },
props: { workflowId: workflow.id, readOnly: true },
});
useUIStore().markStateDirty();
@@ -87,6 +87,44 @@ export function useWorkflowSaving({
// for the out-of-tree callers, the way `useRunWorkflow` does for the same key.
const editorContext = getCurrentInstance() ? useEditorContext() : undefined;
const canAutoSave = computed(() => ownsAutoSave && editorContext?.readOnly.value !== true);
const currentWorkflowDocumentStore = computed(() =>
useWorkflowDocumentStore(createWorkflowDocumentId(workflowId.value)),
);
const canScheduleAutoSave = computed(() => {
// Don't schedule from a read-only canvas, or from an instance that doesn't
// own one. Every autosave entry point funnels through here, so a preview
// never writes whatever marked it dirty.
if (!canAutoSave.value) {
return false;
}
// Don't schedule if autosave is disabled via environment variable
if (!settingsStore.isAutosaveEnabled) {
return false;
}
// Don't schedule if a save is already in progress - the finally block
// will reschedule if there are pending changes
if (saveStore.pendingSave) {
return false;
}
// Don't schedule if we're waiting for retry backoff to complete
if (saveStore.isRetrying) {
return false;
}
// Don't schedule if we're offline
if (!backendConnectionStore.isOnline) {
return false;
}
if (!currentWorkflowDocumentStore.value.hydrated) {
return false;
}
return true;
});
async function promptSaveUnsavedWorkflowChanges(
next: NavigationGuardNext,
@@ -141,6 +179,7 @@ export function useWorkflowSaving({
await cancel();
uiStore.markStateClean();
cancelAutoSave();
next();
return;
@@ -600,8 +639,8 @@ export function useWorkflowSaving({
return;
}
// Check if another save is already in progress
if (saveStore.pendingSave) {
if (!uiStore.stateIsDirty || !canScheduleAutoSave.value) {
saveStore.setAutoSaveState(AutoSaveState.Idle);
return;
}
@@ -615,7 +654,7 @@ export function useWorkflowSaving({
saveStore.setAutoSaveState(AutoSaveState.Idle);
}
// If changes were made during save, reschedule autosave
if (uiStore.stateIsDirty && !saveStore.isRetrying) {
if (uiStore.stateIsDirty && canScheduleAutoSave.value) {
saveStore.setAutoSaveState(AutoSaveState.Scheduled);
void autoSaveWorkflowDebounced();
}
@@ -627,31 +666,7 @@ export function useWorkflowSaving({
);
const scheduleAutoSave = () => {
// Don't schedule from a read-only canvas, or from an instance that doesn't
// own one. Every autosave entry point funnels through here, so a preview
// never writes whatever marked it dirty.
if (!canAutoSave.value) {
return;
}
// Don't schedule if autosave is disabled via environment variable
if (!settingsStore.isAutosaveEnabled) {
return;
}
// Don't schedule if a save is already in progress - the finally block
// will reschedule if there are pending changes
if (saveStore.pendingSave) {
return;
}
// Don't schedule if we're waiting for retry backoff to complete
if (saveStore.isRetrying) {
return;
}
// Don't schedule if we're offline
if (!backendConnectionStore.isOnline) {
if (!canScheduleAutoSave.value) {
return;
}
@@ -676,23 +691,13 @@ export function useWorkflowSaving({
// Instance AI preview locks the canvas while its agent edits, and nothing
// else would save what the agent wrote. Mirrors the AI-builder re-arm in
// NodeView.
watch(canAutoSave, (allowed, wasAllowed) => {
// Watch for network coming back online, and for other autosave eligibility
// returning after retry backoff, save completion, or document hydration.
watch(canScheduleAutoSave, (allowed, wasAllowed) => {
if (allowed && !wasAllowed && uiStore.stateIsDirty) {
scheduleAutoSave();
}
});
// Watch for network coming back online
watch(
() => backendConnectionStore.isOnline,
(isOnline, wasOnline) => {
if (isOnline && !wasOnline) {
if (uiStore.stateIsDirty) {
scheduleAutoSave();
}
}
},
);
}
return {
@@ -361,8 +361,11 @@ describe('workflowDocument.store orchestration', () => {
const store = useWorkflowDocumentStore(createWorkflowDocumentId('wf-1'));
const workflow = buildFullWorkflow();
expect(store.hydrated).toBe(false);
store.hydrate(workflow);
expect(store.hydrated).toBe(true);
expect(store.name).toBe('My Workflow');
expect(store.description).toBe('Sample description');
expect(store.activeVersionId).toBe('ver-123');
@@ -435,6 +438,15 @@ describe('workflowDocument.store orchestration', () => {
expect(store.pinnedDataByNodeName).toEqual({});
});
it('clears hydrated state on reset', () => {
const store = useWorkflowDocumentStore(createWorkflowDocumentId('wf-1'));
store.hydrate(buildFullWorkflow());
store.reset();
expect(store.hydrated).toBe(false);
});
it('derives homeProject from the shared owner relation when homeProject is absent', () => {
// `GET /workflows/:id` omits the assembled `homeProject` when the sharing
// license is inactive, returning the raw `shared` relation instead.
@@ -1,6 +1,6 @@
import { defineStore, getActivePinia } from 'pinia';
import { STORES } from '@n8n/stores';
import { computed, inject, provide, shallowRef, watchEffect, type ShallowRef } from 'vue';
import { computed, inject, provide, ref, shallowRef, watchEffect, type ShallowRef } from 'vue';
import { WorkflowDocumentStoreKey } from '@/app/constants/injectionKeys';
import { useWorkflowDocumentActive } from './workflowDocument/useWorkflowDocumentActive';
import { useWorkflowDocumentHomeProject } from './workflowDocument/useWorkflowDocumentHomeProject';
@@ -176,6 +176,7 @@ export function getWorkflowDocumentStoreId(id: string) {
export function useWorkflowDocumentStore(id: WorkflowDocumentId) {
return defineStore(getWorkflowDocumentStoreId(id), () => {
const [workflowId, workflowVersion] = id.split('@');
const hydrated = ref(false);
const nodeTypesStore = useNodeTypesStore();
@@ -298,6 +299,10 @@ export function useWorkflowDocumentStore(id: WorkflowDocumentId) {
return data;
}
function setHydrated(value: boolean) {
hydrated.value = value;
}
function hydrate(workflow: IWorkflowDb) {
if (workflow.id !== workflowId) {
throw new Error(
@@ -356,9 +361,11 @@ export function useWorkflowDocumentStore(id: WorkflowDocumentId) {
settings: workflow.settings ?? { ...DEFAULT_SETTINGS },
pinData: workflow.pinData ?? {},
});
setHydrated(true);
}
function reset() {
setHydrated(false);
workflowDocumentName.setName('');
workflowDocumentDescription.setDescription('');
workflowDocumentActive.setActiveState({ activeVersionId: null, activeVersion: null });
@@ -448,6 +455,7 @@ export function useWorkflowDocumentStore(id: WorkflowDocumentId) {
documentId: id,
workflowId,
workflowVersion,
hydrated,
...workflowDocumentName,
...workflowDocumentActive,
...workflowDocumentPublicationStatus,
@@ -474,6 +482,7 @@ export function useWorkflowDocumentStore(id: WorkflowDocumentId) {
...workflowDocumentNodesIssues,
...workflowDocumentNodeGroups,
removeAllNodes,
setHydrated,
hydrate,
reset,
getSnapshot,