Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
74e3dba98f | ||
|
|
2ebff5f4d7 | ||
|
|
b8e7179a85 | ||
|
|
b1a7155431 | ||
|
|
a303d9ca1b | ||
|
|
d225d132f9 | ||
|
|
020000b3c5 | ||
|
|
0a94f93f0c |
@@ -1,5 +1,4 @@
|
||||
import { gql } from '@apollo/client';
|
||||
import { CombinedGraphQLErrors } from '@apollo/client/errors';
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import fetchMock, { enableFetchMocks } from 'jest-fetch-mock';
|
||||
import { MemoryRouter, useLocation } from 'react-router-dom';
|
||||
@@ -8,6 +7,13 @@ import { useApolloFactory } from '@/apollo/hooks/useApolloFactory';
|
||||
|
||||
enableFetchMocks();
|
||||
|
||||
jest.mock('@/apollo/utils/getTokenPair', () => ({
|
||||
getTokenPair: jest.fn().mockReturnValue({
|
||||
accessOrWorkspaceAgnosticToken: { token: 'testAccessToken', expiresAt: '' },
|
||||
refreshToken: { token: 'testRefreshToken', expiresAt: '' },
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockNavigate = jest.fn();
|
||||
|
||||
jest.mock('react-router-dom', () => {
|
||||
@@ -88,10 +94,7 @@ describe('useApolloFactory', () => {
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(CombinedGraphQLErrors);
|
||||
expect((error as CombinedGraphQLErrors).message).toBe(
|
||||
'Error message not found.',
|
||||
);
|
||||
expect(error).toBeDefined();
|
||||
|
||||
expect(mockNavigate).toHaveBeenCalled();
|
||||
expect(mockNavigate).toHaveBeenCalledWith('/welcome');
|
||||
|
||||
@@ -28,6 +28,13 @@ jest.mock('@/auth/services/AuthService', () => {
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('@/apollo/utils/getTokenPair', () => ({
|
||||
getTokenPair: jest.fn().mockReturnValue({
|
||||
accessOrWorkspaceAgnosticToken: { token: 'testAccessToken', expiresAt: '' },
|
||||
refreshToken: { token: 'testRefreshToken', expiresAt: '' },
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockOnError = jest.fn();
|
||||
const mockOnNetworkError = jest.fn();
|
||||
const mockOnPayloadTooLarge = jest.fn();
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
import { setContext } from '@apollo/client/link/context';
|
||||
import { ErrorLink } from '@apollo/client/link/error';
|
||||
import { RetryLink } from '@apollo/client/link/retry';
|
||||
import { from, switchMap } from 'rxjs';
|
||||
import { EMPTY, from, switchMap } from 'rxjs';
|
||||
import { RestLink } from 'apollo-link-rest';
|
||||
import UploadHttpLink from 'apollo-upload-client/UploadHttpLink.mjs';
|
||||
|
||||
@@ -41,7 +41,7 @@ const logger = loggerLink(() => 'Twenty');
|
||||
// Shared across all ApolloFactory instances so concurrent
|
||||
// UNAUTHENTICATED errors from /graphql and /metadata clients
|
||||
// deduplicate into a single renewal request.
|
||||
let renewalPromise: Promise<void> | null = null;
|
||||
let renewalPromise: Promise<boolean> | null = null;
|
||||
|
||||
const TOKEN_RENEWAL_MAX_RETRIES = 3;
|
||||
const TOKEN_RENEWAL_RETRY_DELAY_MS = 1000;
|
||||
@@ -181,21 +181,32 @@ export class ApolloFactory implements ApolloManager {
|
||||
operation: ApolloLink.Operation,
|
||||
forward: ApolloLink.ForwardFunction,
|
||||
) => {
|
||||
if (!getTokenPair()) {
|
||||
onUnauthenticatedError?.();
|
||||
|
||||
return EMPTY;
|
||||
}
|
||||
|
||||
if (!renewalPromise) {
|
||||
renewalPromise = attemptTokenRenewal()
|
||||
.then(() => true)
|
||||
.catch(() => {
|
||||
// oxlint-disable-next-line no-console
|
||||
console.log(
|
||||
'Failed to renew token after retries, triggering unauthenticated error',
|
||||
);
|
||||
onUnauthenticatedError?.();
|
||||
|
||||
return false;
|
||||
})
|
||||
.finally(() => {
|
||||
renewalPromise = null;
|
||||
});
|
||||
}
|
||||
|
||||
return from(renewalPromise).pipe(switchMap(() => forward(operation)));
|
||||
return from(renewalPromise).pipe(
|
||||
switchMap((succeeded) => (succeeded ? forward(operation) : EMPTY)),
|
||||
);
|
||||
};
|
||||
|
||||
const sendToSentry = ({
|
||||
|
||||
+1
@@ -6,6 +6,7 @@ export const FIND_MANY_FRONT_COMPONENTS = gql`
|
||||
id
|
||||
name
|
||||
applicationId
|
||||
isHeadless
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
+6
-1
@@ -1,5 +1,6 @@
|
||||
import { isLayoutCustomizationModeEnabledState } from '@/layout-customization/states/isLayoutCustomizationModeEnabledState';
|
||||
import { PageLayoutEditModeProviderContext } from '@/page-layout/contexts/PageLayoutEditModeContext';
|
||||
import { useLayoutRenderingContext } from '@/ui/layout/contexts/LayoutRenderingContext';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import { type ReactNode } from 'react';
|
||||
|
||||
@@ -14,9 +15,13 @@ export const RecordPageLayoutEditModeProvider = ({
|
||||
isLayoutCustomizationModeEnabledState,
|
||||
);
|
||||
|
||||
const { isInSidePanel } = useLayoutRenderingContext();
|
||||
|
||||
return (
|
||||
<PageLayoutEditModeProviderContext
|
||||
value={{ isInEditMode: isLayoutCustomizationModeEnabled }}
|
||||
value={{
|
||||
isInEditMode: isLayoutCustomizationModeEnabled && !isInSidePanel,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</PageLayoutEditModeProviderContext>
|
||||
|
||||
+14
-5
@@ -2,6 +2,7 @@ import { PageLayoutEditModeProvider } from '@/page-layout/components/PageLayoutE
|
||||
import { PageLayoutComponentInstanceContext } from '@/page-layout/states/contexts/PageLayoutComponentInstanceContext';
|
||||
import { pageLayoutPersistedComponentState } from '@/page-layout/states/pageLayoutPersistedComponentState';
|
||||
import { getTabListInstanceIdFromPageLayoutId } from '@/page-layout/utils/getTabListInstanceIdFromPageLayoutId';
|
||||
import { LayoutRenderingProvider } from '@/ui/layout/contexts/LayoutRenderingContext';
|
||||
import { TabListComponentInstanceContext } from '@/ui/layout/tab-list/states/contexts/TabListComponentInstanceContext';
|
||||
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
|
||||
import {
|
||||
@@ -33,12 +34,20 @@ const PageLayoutTestEditModeProvider = ({
|
||||
layoutType ?? pageLayoutPersisted?.type ?? PageLayoutType.DASHBOARD;
|
||||
|
||||
return (
|
||||
<PageLayoutEditModeProvider
|
||||
layoutType={resolvedLayoutType}
|
||||
pageLayoutId={instanceId}
|
||||
<LayoutRenderingProvider
|
||||
value={{
|
||||
isInSidePanel: false,
|
||||
targetRecordIdentifier: undefined,
|
||||
layoutType: resolvedLayoutType,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</PageLayoutEditModeProvider>
|
||||
<PageLayoutEditModeProvider
|
||||
layoutType={resolvedLayoutType}
|
||||
pageLayoutId={instanceId}
|
||||
>
|
||||
{children}
|
||||
</PageLayoutEditModeProvider>
|
||||
</LayoutRenderingProvider>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
import { pageLayoutDraftComponentState } from '@/page-layout/states/pageLayoutDraftComponentState';
|
||||
import { type PageLayoutWidget } from '@/page-layout/types/PageLayoutWidget';
|
||||
import { addWidgetToTab } from '@/page-layout/utils/addWidgetToTab';
|
||||
import { createDefaultFrontComponentWidgetForVerticalList } from '@/page-layout/utils/createDefaultFrontComponentWidgetForVerticalList';
|
||||
import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState';
|
||||
import { useAtomComponentStateCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateCallbackState';
|
||||
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
|
||||
import { useStore } from 'jotai';
|
||||
import { useCallback } from 'react';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
export const useCreateRecordPageFrontComponentWidget = ({
|
||||
pageLayoutId,
|
||||
tabListInstanceId,
|
||||
}: {
|
||||
pageLayoutId: string;
|
||||
tabListInstanceId: string;
|
||||
}) => {
|
||||
const activeTabId = useAtomComponentStateValue(
|
||||
activeTabIdComponentState,
|
||||
tabListInstanceId,
|
||||
);
|
||||
|
||||
const pageLayoutDraft = useAtomComponentStateValue(
|
||||
pageLayoutDraftComponentState,
|
||||
pageLayoutId,
|
||||
);
|
||||
|
||||
const pageLayoutDraftState = useAtomComponentStateCallbackState(
|
||||
pageLayoutDraftComponentState,
|
||||
pageLayoutId,
|
||||
);
|
||||
|
||||
const store = useStore();
|
||||
|
||||
const createRecordPageFrontComponentWidget = useCallback(
|
||||
(title: string, frontComponentId: string): PageLayoutWidget | undefined => {
|
||||
if (!isDefined(activeTabId)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const activeTab = pageLayoutDraft.tabs.find(
|
||||
(tab) => tab.id === activeTabId,
|
||||
);
|
||||
const existingWidgets = activeTab?.widgets ?? [];
|
||||
|
||||
const positionIndex = existingWidgets.length;
|
||||
const widgetId = uuidv4();
|
||||
|
||||
const newWidget = createDefaultFrontComponentWidgetForVerticalList({
|
||||
id: widgetId,
|
||||
pageLayoutTabId: activeTabId,
|
||||
title,
|
||||
frontComponentId,
|
||||
positionIndex,
|
||||
});
|
||||
|
||||
store.set(pageLayoutDraftState, (prev) => ({
|
||||
...prev,
|
||||
tabs: addWidgetToTab(prev.tabs, activeTabId, newWidget),
|
||||
}));
|
||||
|
||||
return newWidget;
|
||||
},
|
||||
[activeTabId, pageLayoutDraft.tabs, pageLayoutDraftState, store],
|
||||
);
|
||||
|
||||
return { createRecordPageFrontComponentWidget };
|
||||
};
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
import { type PageLayoutWidget } from '@/page-layout/types/PageLayoutWidget';
|
||||
import {
|
||||
PageLayoutTabLayoutMode,
|
||||
WidgetConfigurationType,
|
||||
WidgetType,
|
||||
} from '~/generated-metadata/graphql';
|
||||
|
||||
export const createDefaultFrontComponentWidgetForVerticalList = ({
|
||||
id,
|
||||
pageLayoutTabId,
|
||||
title,
|
||||
frontComponentId,
|
||||
positionIndex,
|
||||
}: {
|
||||
id: string;
|
||||
pageLayoutTabId: string;
|
||||
title: string;
|
||||
frontComponentId: string;
|
||||
positionIndex: number;
|
||||
}): PageLayoutWidget => {
|
||||
return {
|
||||
__typename: 'PageLayoutWidget',
|
||||
id,
|
||||
pageLayoutTabId,
|
||||
title,
|
||||
type: WidgetType.FRONT_COMPONENT,
|
||||
configuration: {
|
||||
__typename: 'FrontComponentConfiguration',
|
||||
configurationType: WidgetConfigurationType.FRONT_COMPONENT,
|
||||
frontComponentId,
|
||||
},
|
||||
gridPosition: {
|
||||
__typename: 'GridPosition',
|
||||
row: 0,
|
||||
column: 0,
|
||||
rowSpan: 1,
|
||||
columnSpan: 12,
|
||||
},
|
||||
position: {
|
||||
__typename: 'PageLayoutWidgetVerticalListPosition',
|
||||
layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST,
|
||||
index: positionIndex,
|
||||
},
|
||||
objectMetadataId: null,
|
||||
isOverridden: false,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
deletedAt: null,
|
||||
};
|
||||
};
|
||||
+14
-3
@@ -3,13 +3,18 @@ import { Suspense, lazy } from 'react';
|
||||
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { usePageLayoutContentContext } from '@/page-layout/contexts/PageLayoutContentContext';
|
||||
import { useIsPageLayoutInEditMode } from '@/page-layout/hooks/useIsPageLayoutInEditMode';
|
||||
import { type PageLayoutWidget } from '@/page-layout/types/PageLayoutWidget';
|
||||
import { PageLayoutWidgetNoDataDisplay } from '@/page-layout/widgets/components/PageLayoutWidgetNoDataDisplay';
|
||||
import { isWidgetConfigurationOfType } from '@/side-panel/pages/page-layout/utils/isWidgetConfigurationOfType';
|
||||
import { PageLayoutTabLayoutMode } from '~/generated-metadata/graphql';
|
||||
|
||||
const StyledContainer = styled.div<{ isInEditMode: boolean }>`
|
||||
height: 100%;
|
||||
const StyledContainer = styled.div<{
|
||||
isInEditMode: boolean;
|
||||
isVerticalList: boolean;
|
||||
}>`
|
||||
height: ${({ isVerticalList }) => (isVerticalList ? '300px' : '100%')};
|
||||
overflow: auto;
|
||||
pointer-events: ${({ isInEditMode }) => (isInEditMode ? 'none' : 'auto')};
|
||||
width: 100%;
|
||||
@@ -29,6 +34,9 @@ export const FrontComponentWidgetRenderer = ({
|
||||
widget,
|
||||
}: FrontComponentWidgetRendererProps) => {
|
||||
const isPageLayoutInEditMode = useIsPageLayoutInEditMode();
|
||||
const { layoutMode } = usePageLayoutContentContext();
|
||||
|
||||
const isVerticalList = layoutMode === PageLayoutTabLayoutMode.VERTICAL_LIST;
|
||||
|
||||
const configuration = widget.configuration;
|
||||
|
||||
@@ -42,7 +50,10 @@ export const FrontComponentWidgetRenderer = ({
|
||||
const frontComponentId = configuration.frontComponentId;
|
||||
|
||||
return (
|
||||
<StyledContainer isInEditMode={isPageLayoutInEditMode}>
|
||||
<StyledContainer
|
||||
isInEditMode={isPageLayoutInEditMode}
|
||||
isVerticalList={isVerticalList}
|
||||
>
|
||||
<Suspense fallback={null}>
|
||||
<FrontComponentRenderer frontComponentId={frontComponentId} />
|
||||
</Suspense>
|
||||
|
||||
+41
-5
@@ -5,10 +5,12 @@ import { useCreatePageLayoutGraphWidget } from '@/page-layout/hooks/useCreatePag
|
||||
import { useCreatePageLayoutIframeWidget } from '@/page-layout/hooks/useCreatePageLayoutIframeWidget';
|
||||
import { useCreatePageLayoutRecordTableWidget } from '@/page-layout/hooks/useCreatePageLayoutRecordTableWidget';
|
||||
import { useCreatePageLayoutStandaloneRichTextWidget } from '@/page-layout/hooks/useCreatePageLayoutStandaloneRichTextWidget';
|
||||
import { useCreateRecordPageFrontComponentWidget } from '@/page-layout/hooks/useCreateRecordPageFrontComponentWidget';
|
||||
import { useOpportunityDefaultChartConfig } from '@/page-layout/hooks/useOpportunityDefaultChartConfig';
|
||||
import { useRemovePageLayoutWidgetAndPreservePosition } from '@/page-layout/hooks/useRemovePageLayoutWidgetAndPreservePosition';
|
||||
import { pageLayoutDraftComponentState } from '@/page-layout/states/pageLayoutDraftComponentState';
|
||||
import { pageLayoutEditingWidgetIdComponentState } from '@/page-layout/states/pageLayoutEditingWidgetIdComponentState';
|
||||
import { getTabLayoutMode } from '@/page-layout/utils/getTabLayoutMode';
|
||||
import { getTabListInstanceIdFromPageLayoutAndRecord } from '@/page-layout/utils/getTabListInstanceIdFromPageLayoutAndRecord';
|
||||
import { SidePanelGroup } from '@/side-panel/components/SidePanelGroup';
|
||||
import { SidePanelList } from '@/side-panel/components/SidePanelList';
|
||||
@@ -18,6 +20,7 @@ import { usePageLayoutIdFromContextStore } from '@/side-panel/pages/page-layout/
|
||||
import { getFrontComponentWidgetTypeSelectItemId } from '@/side-panel/pages/page-layout/utils/getFrontComponentWidgetTypeSelectItemId';
|
||||
import { isExistingWidgetMissingOrDifferentType } from '@/side-panel/pages/page-layout/utils/isExistingWidgetMissingOrDifferentType';
|
||||
import { SelectableListItem } from '@/ui/layout/selectable-list/components/SelectableListItem';
|
||||
import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState';
|
||||
import { useAtomComponentState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentState';
|
||||
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
|
||||
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
|
||||
@@ -35,6 +38,7 @@ import {
|
||||
import {
|
||||
FeatureFlagKey,
|
||||
type FrontComponent,
|
||||
PageLayoutTabLayoutMode,
|
||||
WidgetType,
|
||||
} from '~/generated-metadata/graphql';
|
||||
|
||||
@@ -80,6 +84,27 @@ export const SidePanelPageLayoutWidgetTypeSelect = () => {
|
||||
tabListInstanceId,
|
||||
});
|
||||
|
||||
const { createRecordPageFrontComponentWidget } =
|
||||
useCreateRecordPageFrontComponentWidget({
|
||||
pageLayoutId,
|
||||
tabListInstanceId,
|
||||
});
|
||||
|
||||
const activeTabId = useAtomComponentStateValue(
|
||||
activeTabIdComponentState,
|
||||
tabListInstanceId,
|
||||
);
|
||||
|
||||
const activeTab = pageLayoutDraft.tabs.find((tab) => tab.id === activeTabId);
|
||||
|
||||
const activeTabLayoutMode = getTabLayoutMode({
|
||||
tab: activeTab,
|
||||
pageLayoutType: pageLayoutDraft.type,
|
||||
});
|
||||
|
||||
const isVerticalList =
|
||||
activeTabLayoutMode === PageLayoutTabLayoutMode.VERTICAL_LIST;
|
||||
|
||||
const { createPageLayoutRecordTableWidget } =
|
||||
useCreatePageLayoutRecordTableWidget(pageLayoutId);
|
||||
|
||||
@@ -94,7 +119,9 @@ export const SidePanelPageLayoutWidgetTypeSelect = () => {
|
||||
frontComponents: FrontComponent[];
|
||||
}>(FIND_MANY_FRONT_COMPONENTS);
|
||||
|
||||
const frontComponents = frontComponentsData?.frontComponents ?? [];
|
||||
const frontComponents = (frontComponentsData?.frontComponents ?? []).filter(
|
||||
(frontComponent) => !frontComponent.isHeadless,
|
||||
);
|
||||
|
||||
const frontComponentsWithSelectItemId = frontComponents.map(
|
||||
(frontComponent) => ({
|
||||
@@ -212,10 +239,19 @@ export const SidePanelPageLayoutWidgetTypeSelect = () => {
|
||||
removePageLayoutWidgetAndPreservePosition(pageLayoutEditingWidgetId);
|
||||
}
|
||||
|
||||
const newWidget = createPageLayoutFrontComponentWidget(
|
||||
frontComponent.name,
|
||||
frontComponent.id,
|
||||
);
|
||||
const newWidget = isVerticalList
|
||||
? createRecordPageFrontComponentWidget(
|
||||
frontComponent.name,
|
||||
frontComponent.id,
|
||||
)
|
||||
: createPageLayoutFrontComponentWidget(
|
||||
frontComponent.name,
|
||||
frontComponent.id,
|
||||
);
|
||||
|
||||
if (!isDefined(newWidget)) {
|
||||
return;
|
||||
}
|
||||
setPageLayoutEditingWidgetId(newWidget.id);
|
||||
}
|
||||
|
||||
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
import { type MigrationInterface, type QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddViewFieldGroupIdIndexOnViewField1775129420309
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = 'AddViewFieldGroupIdIndexOnViewField1775129420309';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX IF NOT EXISTS "IDX_VIEW_FIELD_VIEW_FIELD_GROUP_ID" ON "core"."viewField" ("viewFieldGroupId")`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`DROP INDEX IF EXISTS "core"."IDX_VIEW_FIELD_VIEW_FIELD_GROUP_ID"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,10 @@
|
||||
import { Test, type TestingModule } from '@nestjs/testing';
|
||||
|
||||
import { ScalarsExplorerService } from 'src/engine/api/graphql/services/scalars-explorer.service';
|
||||
import { WorkspaceGraphqlSchemaSDLService } from 'src/engine/api/graphql/workspace-graphql-schema-sdl/workspace-graphql-schema-sdl.service';
|
||||
import { WorkspaceResolverFactory } from 'src/engine/api/graphql/workspace-resolver-builder/workspace-resolver.factory';
|
||||
import { WorkspaceGraphQLSchemaGenerator } from 'src/engine/api/graphql/workspace-schema-builder/workspace-graphql-schema.factory';
|
||||
import { WorkspaceSchemaFactory } from 'src/engine/api/graphql/workspace-schema.factory';
|
||||
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
|
||||
import { MetricsService } from 'src/engine/core-modules/metrics/metrics.service';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service';
|
||||
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
|
||||
import { ObjectMetadataService } from 'src/engine/metadata-modules/object-metadata/object-metadata.service';
|
||||
import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service';
|
||||
|
||||
describe('WorkspaceSchemaFactory', () => {
|
||||
let service: WorkspaceSchemaFactory;
|
||||
@@ -19,40 +13,16 @@ describe('WorkspaceSchemaFactory', () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
WorkspaceSchemaFactory,
|
||||
{
|
||||
provide: DataSourceService,
|
||||
useValue: {},
|
||||
},
|
||||
{
|
||||
provide: ObjectMetadataService,
|
||||
useValue: {},
|
||||
},
|
||||
{
|
||||
provide: ScalarsExplorerService,
|
||||
useValue: {},
|
||||
},
|
||||
{
|
||||
provide: WorkspaceGraphQLSchemaGenerator,
|
||||
useValue: {},
|
||||
},
|
||||
{
|
||||
provide: WorkspaceResolverFactory,
|
||||
useValue: {},
|
||||
},
|
||||
{
|
||||
provide: WorkspaceCacheStorageService,
|
||||
useValue: {},
|
||||
},
|
||||
{
|
||||
provide: WorkspaceManyOrAllFlatEntityMapsCacheService,
|
||||
useValue: {},
|
||||
},
|
||||
{
|
||||
provide: FeatureFlagService,
|
||||
useValue: {},
|
||||
},
|
||||
{
|
||||
provide: TwentyConfigService,
|
||||
provide: WorkspaceGraphqlSchemaSDLService,
|
||||
useValue: {},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,24 +1,16 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { ScalarsExplorerService } from 'src/engine/api/graphql/services/scalars-explorer.service';
|
||||
import { WorkspaceGraphqlSchemaSDLModule } from 'src/engine/api/graphql/workspace-graphql-schema-sdl/workspace-graphql-schema-sdl.module';
|
||||
import { WorkspaceResolverBuilderModule } from 'src/engine/api/graphql/workspace-resolver-builder/workspace-resolver-builder.module';
|
||||
import { WorkspaceSchemaBuilderModule } from 'src/engine/api/graphql/workspace-schema-builder/workspace-schema-builder.module';
|
||||
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
|
||||
import { MetricsModule } from 'src/engine/core-modules/metrics/metrics.module';
|
||||
import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module';
|
||||
import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module';
|
||||
import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module';
|
||||
|
||||
import { MetricsModule } from 'src/engine/core-modules/metrics/metrics.module';
|
||||
import { WorkspaceSchemaFactory } from './workspace-schema.factory';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
DataSourceModule,
|
||||
WorkspaceSchemaBuilderModule,
|
||||
WorkspaceResolverBuilderModule,
|
||||
WorkspaceCacheStorageModule,
|
||||
WorkspaceManyOrAllFlatEntityMapsCacheModule,
|
||||
FeatureFlagModule,
|
||||
WorkspaceGraphqlSchemaSDLModule,
|
||||
MetricsModule,
|
||||
],
|
||||
providers: [WorkspaceSchemaFactory, ScalarsExplorerService],
|
||||
|
||||
+4
@@ -4,6 +4,8 @@ import { CoreCommonApiModule } from 'src/engine/api/common/core-common-api.modul
|
||||
import { DirectExecutionService } from 'src/engine/api/graphql/direct-execution/direct-execution.service';
|
||||
import { WorkspaceResolverNameMapCacheService } from 'src/engine/api/graphql/direct-execution/services/workspace-resolver-name-map-cache.service';
|
||||
import { WorkspaceResolverBuilderModule } from 'src/engine/api/graphql/workspace-resolver-builder/workspace-resolver-builder.module';
|
||||
import { WorkspaceGraphqlSchemaSDLModule } from 'src/engine/api/graphql/workspace-graphql-schema-sdl/workspace-graphql-schema-sdl.module';
|
||||
import { MetricsModule } from 'src/engine/core-modules/metrics/metrics.module';
|
||||
import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module';
|
||||
import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module';
|
||||
|
||||
@@ -13,6 +15,8 @@ import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache
|
||||
WorkspaceManyOrAllFlatEntityMapsCacheModule,
|
||||
WorkspaceCacheModule,
|
||||
WorkspaceResolverBuilderModule,
|
||||
WorkspaceGraphqlSchemaSDLModule,
|
||||
MetricsModule,
|
||||
],
|
||||
providers: [DirectExecutionService, WorkspaceResolverNameMapCacheService],
|
||||
exports: [DirectExecutionService],
|
||||
|
||||
+90
-1
@@ -4,6 +4,8 @@ import { type MessageDescriptor } from '@lingui/core';
|
||||
import { type Request } from 'express';
|
||||
import {
|
||||
GraphQLError,
|
||||
buildSchema,
|
||||
execute,
|
||||
type DocumentNode,
|
||||
type FieldNode,
|
||||
type GraphQLFormattedError,
|
||||
@@ -40,6 +42,7 @@ import { graphQLBuildFragmentMap } from 'src/engine/api/graphql/direct-execution
|
||||
import { graphQLBuildPartialResolveInfo } from 'src/engine/api/graphql/direct-execution/utils/graphql-build-partial-resolve-info.util';
|
||||
import { graphQLExtractTopLevelFields } from 'src/engine/api/graphql/direct-execution/utils/graphql-extract-top-level-fields.util';
|
||||
import { graphQLFormatResultFromSelectedFields } from 'src/engine/api/graphql/direct-execution/utils/graphql-format-result-from-selected-fields.util';
|
||||
import { WorkspaceGraphqlSchemaSDLService } from 'src/engine/api/graphql/workspace-graphql-schema-sdl/workspace-graphql-schema-sdl.service';
|
||||
import { ResolverOutput } from 'src/engine/api/graphql/workspace-query-runner/interfaces/resolver-output';
|
||||
import { workspaceQueryRunnerGraphqlApiExceptionHandler } from 'src/engine/api/graphql/workspace-query-runner/utils/workspace-query-runner-graphql-api-exception-handler.util';
|
||||
import { RESOLVER_METHOD_NAMES } from 'src/engine/api/graphql/workspace-resolver-builder/constants/resolver-method-names';
|
||||
@@ -62,6 +65,8 @@ import { type WorkspaceResolverBuilderFactoryInterface } from 'src/engine/api/gr
|
||||
import { type WorkspaceSchemaBuilderContext } from 'src/engine/api/graphql/workspace-schema-builder/interfaces/workspace-schema-builder-context.interface';
|
||||
import { UserInputError } from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
|
||||
import { I18nService } from 'src/engine/core-modules/i18n/i18n.service';
|
||||
import { MetricsService } from 'src/engine/core-modules/metrics/metrics.service';
|
||||
import { MetricsKeys } from 'src/engine/core-modules/metrics/types/metrics-keys.type';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
|
||||
import { buildObjectIdByNameMaps } from 'src/engine/metadata-modules/flat-object-metadata/utils/build-object-id-by-name-maps.util';
|
||||
@@ -84,8 +89,10 @@ export class DirectExecutionService {
|
||||
constructor(
|
||||
private readonly workspaceFlatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService,
|
||||
private readonly workspaceCacheService: WorkspaceCacheService,
|
||||
private readonly workspaceGraphqlSchemaSDLService: WorkspaceGraphqlSchemaSDLService,
|
||||
private readonly twentyConfigService: TwentyConfigService,
|
||||
private readonly i18nService: I18nService,
|
||||
private readonly metricsService: MetricsService,
|
||||
private readonly findManyResolverFactory: FindManyResolverFactory,
|
||||
private readonly findOneResolverFactory: FindOneResolverFactory,
|
||||
private readonly findDuplicatesResolverFactory: FindDuplicatesResolverFactory,
|
||||
@@ -144,7 +151,7 @@ export class DirectExecutionService {
|
||||
]);
|
||||
}
|
||||
|
||||
async getGeneratedWorkspaceResolverNames(
|
||||
async getWorkspaceResolverNames(
|
||||
workspaceId: string,
|
||||
): Promise<Set<string> | null> {
|
||||
const { graphQLResolverNameMap } =
|
||||
@@ -158,6 +165,25 @@ export class DirectExecutionService {
|
||||
async execute(
|
||||
req: Request,
|
||||
document: DocumentNode,
|
||||
hasIntrospectionFields: boolean,
|
||||
hasWorkspaceFields: boolean,
|
||||
): Promise<DirectExecutionResult | null> {
|
||||
const [introspectionResult, workspaceResult] = await Promise.all([
|
||||
hasIntrospectionFields
|
||||
? this.executeIntrospectionQuery(req, document)
|
||||
: null,
|
||||
hasWorkspaceFields ? this.executeWorkspaceQuery(req, document) : null,
|
||||
]);
|
||||
|
||||
return this.mergeDirectExecutionResults(
|
||||
introspectionResult,
|
||||
workspaceResult,
|
||||
);
|
||||
}
|
||||
|
||||
private async executeWorkspaceQuery(
|
||||
req: Request,
|
||||
document: DocumentNode,
|
||||
): Promise<DirectExecutionResult | null> {
|
||||
try {
|
||||
const workspaceId = req.workspace?.id;
|
||||
@@ -255,6 +281,69 @@ export class DirectExecutionService {
|
||||
}
|
||||
}
|
||||
|
||||
private async executeIntrospectionQuery(
|
||||
req: Request,
|
||||
document: DocumentNode,
|
||||
): Promise<DirectExecutionResult | null> {
|
||||
try {
|
||||
if (!isDefined(req.workspace)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const schemaSDLResult =
|
||||
await this.workspaceGraphqlSchemaSDLService.getOrComputeSchemaSDL(
|
||||
req.workspace,
|
||||
req.application?.id ?? undefined,
|
||||
);
|
||||
|
||||
if (!isDefined(schemaSDLResult)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const schema = buildSchema(schemaSDLResult.sdl);
|
||||
const result = await execute({
|
||||
schema,
|
||||
document,
|
||||
operationName: req.body?.operationName as string | undefined,
|
||||
variableValues: (req.body?.variables as Record<string, unknown>) ?? {},
|
||||
});
|
||||
|
||||
await this.metricsService.incrementCounter({
|
||||
key: MetricsKeys.GraphqlIntrospectionDirectExecution,
|
||||
shouldStoreInCache: false,
|
||||
});
|
||||
|
||||
return {
|
||||
data: result.data as Record<string, unknown> | undefined,
|
||||
errors: result.errors?.map((error) => error.toJSON()),
|
||||
};
|
||||
} catch (error) {
|
||||
return { errors: [this.formatError(error, req)] };
|
||||
}
|
||||
}
|
||||
|
||||
private mergeDirectExecutionResults(
|
||||
introspectionResult: DirectExecutionResult | null,
|
||||
workspaceResult: DirectExecutionResult | null,
|
||||
): DirectExecutionResult | null {
|
||||
if (!introspectionResult && !workspaceResult) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const errors = [
|
||||
...(introspectionResult?.errors ?? []),
|
||||
...(workspaceResult?.errors ?? []),
|
||||
];
|
||||
|
||||
return {
|
||||
data: {
|
||||
...introspectionResult?.data,
|
||||
...workspaceResult?.data,
|
||||
},
|
||||
...(errors.length > 0 ? { errors } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
private async executeField({
|
||||
entry,
|
||||
args,
|
||||
|
||||
+23
-29
@@ -5,9 +5,8 @@ import { FeatureFlagKey } from 'twenty-shared/types';
|
||||
|
||||
import { isNull } from '@sniptt/guards';
|
||||
import { type DirectExecutionService } from 'src/engine/api/graphql/direct-execution/direct-execution.service';
|
||||
import { computeSkipWorkspaceSchemaCreation } from 'src/engine/api/graphql/direct-execution/utils/compute-skip-workspace-schema-creation.util';
|
||||
import { classifyTopLevelFields } from 'src/engine/api/graphql/direct-execution/utils/classify-top-level-fields.util';
|
||||
import { findOperationDefinition } from 'src/engine/api/graphql/direct-execution/utils/find-operation-definition.util';
|
||||
import { hasOnlyGeneratedWorkspaceResolvers } from 'src/engine/api/graphql/direct-execution/utils/has-only-generated-workspace-resolvers.util';
|
||||
import { isSubscriptionOperation } from 'src/engine/api/graphql/direct-execution/utils/is-subscription-operation.util';
|
||||
import { type FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
|
||||
|
||||
@@ -37,15 +36,6 @@ export function useDirectExecution(
|
||||
return;
|
||||
}
|
||||
|
||||
const generatedWorkspaceResolverNames =
|
||||
await config.directExecutionService.getGeneratedWorkspaceResolverNames(
|
||||
req.workspace.id,
|
||||
);
|
||||
|
||||
if (!generatedWorkspaceResolverNames) {
|
||||
return;
|
||||
}
|
||||
|
||||
const queryString = req.body.query as string;
|
||||
const operationName = req.body.operationName as string | undefined;
|
||||
|
||||
@@ -63,28 +53,32 @@ export function useDirectExecution(
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
computeSkipWorkspaceSchemaCreation(
|
||||
queryString,
|
||||
document,
|
||||
operationName,
|
||||
generatedWorkspaceResolverNames,
|
||||
)
|
||||
) {
|
||||
req.skipWorkspaceSchemaCreation = true;
|
||||
}
|
||||
const workspaceResolverNames =
|
||||
await config.directExecutionService.getWorkspaceResolverNames(
|
||||
req.workspace.id,
|
||||
);
|
||||
|
||||
if (
|
||||
!hasOnlyGeneratedWorkspaceResolvers(
|
||||
document,
|
||||
operationName,
|
||||
generatedWorkspaceResolverNames,
|
||||
)
|
||||
) {
|
||||
if (!workspaceResolverNames) {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await config.directExecutionService.execute(req, document);
|
||||
const { hasIntrospectionFields, hasWorkspaceFields, hasCoreFields } =
|
||||
classifyTopLevelFields(document, operationName, workspaceResolverNames);
|
||||
|
||||
if (!hasCoreFields) {
|
||||
req.skipWorkspaceSchemaCreation = true;
|
||||
}
|
||||
|
||||
if (hasCoreFields) {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await config.directExecutionService.execute(
|
||||
req,
|
||||
document,
|
||||
hasIntrospectionFields,
|
||||
hasWorkspaceFields,
|
||||
);
|
||||
|
||||
if (isNull(result)) {
|
||||
return;
|
||||
|
||||
+169
@@ -0,0 +1,169 @@
|
||||
import { parse } from 'graphql';
|
||||
|
||||
import { classifyTopLevelFields } from 'src/engine/api/graphql/direct-execution/utils/classify-top-level-fields.util';
|
||||
|
||||
const WORKSPACE_RESOLVERS = new Set([
|
||||
'findManyCompanies',
|
||||
'findOneCompany',
|
||||
'createOneCompany',
|
||||
'findManyPeople',
|
||||
'findOnePerson',
|
||||
]);
|
||||
|
||||
describe('classifyTopLevelFields', () => {
|
||||
it('should classify a pure introspection query', () => {
|
||||
const query = `
|
||||
query {
|
||||
__schema { types { name } }
|
||||
__type(name: "Company") { name }
|
||||
}
|
||||
`;
|
||||
|
||||
expect(
|
||||
classifyTopLevelFields(parse(query), undefined, WORKSPACE_RESOLVERS),
|
||||
).toEqual({
|
||||
hasIntrospectionFields: true,
|
||||
hasWorkspaceFields: false,
|
||||
hasCoreFields: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('should classify a pure workspace query', () => {
|
||||
const query = `
|
||||
query {
|
||||
findManyCompanies { id name }
|
||||
findOnePerson { id }
|
||||
}
|
||||
`;
|
||||
|
||||
expect(
|
||||
classifyTopLevelFields(parse(query), undefined, WORKSPACE_RESOLVERS),
|
||||
).toEqual({
|
||||
hasIntrospectionFields: false,
|
||||
hasWorkspaceFields: true,
|
||||
hasCoreFields: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('should classify a pure core query', () => {
|
||||
const query = `
|
||||
query {
|
||||
currentWorkspace { id }
|
||||
currentUser { id }
|
||||
}
|
||||
`;
|
||||
|
||||
expect(
|
||||
classifyTopLevelFields(parse(query), undefined, WORKSPACE_RESOLVERS),
|
||||
).toEqual({
|
||||
hasIntrospectionFields: false,
|
||||
hasWorkspaceFields: false,
|
||||
hasCoreFields: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should classify a mixed introspection + workspace query', () => {
|
||||
const query = `
|
||||
query {
|
||||
__schema { types { name } }
|
||||
findManyCompanies { id }
|
||||
}
|
||||
`;
|
||||
|
||||
expect(
|
||||
classifyTopLevelFields(parse(query), undefined, WORKSPACE_RESOLVERS),
|
||||
).toEqual({
|
||||
hasIntrospectionFields: true,
|
||||
hasWorkspaceFields: true,
|
||||
hasCoreFields: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('should classify a mixed workspace + core query', () => {
|
||||
const query = `
|
||||
query {
|
||||
findManyCompanies { id }
|
||||
currentWorkspace { id }
|
||||
}
|
||||
`;
|
||||
|
||||
expect(
|
||||
classifyTopLevelFields(parse(query), undefined, WORKSPACE_RESOLVERS),
|
||||
).toEqual({
|
||||
hasIntrospectionFields: false,
|
||||
hasWorkspaceFields: true,
|
||||
hasCoreFields: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should not classify __typename as introspection', () => {
|
||||
const query = `
|
||||
query {
|
||||
currentWorkspace { id __typename }
|
||||
}
|
||||
`;
|
||||
|
||||
expect(
|
||||
classifyTopLevelFields(parse(query), undefined, WORKSPACE_RESOLVERS),
|
||||
).toEqual({
|
||||
hasIntrospectionFields: false,
|
||||
hasWorkspaceFields: false,
|
||||
hasCoreFields: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should expand fragment spreads when classifying', () => {
|
||||
const query = `
|
||||
query {
|
||||
__schema { types { name } }
|
||||
...WorkspaceFragment
|
||||
}
|
||||
fragment WorkspaceFragment on Query {
|
||||
findManyCompanies { id }
|
||||
}
|
||||
`;
|
||||
|
||||
expect(
|
||||
classifyTopLevelFields(parse(query), undefined, WORKSPACE_RESOLVERS),
|
||||
).toEqual({
|
||||
hasIntrospectionFields: true,
|
||||
hasWorkspaceFields: true,
|
||||
hasCoreFields: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('should respect operationName', () => {
|
||||
const query = `
|
||||
query IntrospectionQuery {
|
||||
__schema { types { name } }
|
||||
}
|
||||
query WorkspaceQuery {
|
||||
findManyCompanies { id }
|
||||
}
|
||||
`;
|
||||
|
||||
expect(
|
||||
classifyTopLevelFields(
|
||||
parse(query),
|
||||
'IntrospectionQuery',
|
||||
WORKSPACE_RESOLVERS,
|
||||
),
|
||||
).toEqual({
|
||||
hasIntrospectionFields: true,
|
||||
hasWorkspaceFields: false,
|
||||
hasCoreFields: false,
|
||||
});
|
||||
|
||||
expect(
|
||||
classifyTopLevelFields(
|
||||
parse(query),
|
||||
'WorkspaceQuery',
|
||||
WORKSPACE_RESOLVERS,
|
||||
),
|
||||
).toEqual({
|
||||
hasIntrospectionFields: false,
|
||||
hasWorkspaceFields: true,
|
||||
hasCoreFields: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
-178
@@ -1,178 +0,0 @@
|
||||
import { parse } from 'graphql';
|
||||
|
||||
import { computeSkipWorkspaceSchemaCreation } from 'src/engine/api/graphql/direct-execution/utils/compute-skip-workspace-schema-creation.util';
|
||||
|
||||
const GENERATED_RESOLVERS = new Set([
|
||||
'findManyCompanies',
|
||||
'findOneCompany',
|
||||
'createOneCompany',
|
||||
'findManyPeople',
|
||||
'findOnePerson',
|
||||
]);
|
||||
|
||||
describe('computeSkipWorkspaceSchemaCreation', () => {
|
||||
it('should return true when all fields are core resolvers', () => {
|
||||
const query = `
|
||||
query {
|
||||
currentWorkspace { id }
|
||||
}
|
||||
`;
|
||||
|
||||
expect(
|
||||
computeSkipWorkspaceSchemaCreation(
|
||||
query,
|
||||
parse(query),
|
||||
undefined,
|
||||
GENERATED_RESOLVERS,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for multiple core resolver fields', () => {
|
||||
const query = `
|
||||
query {
|
||||
currentWorkspace { id }
|
||||
currentUser { id }
|
||||
}
|
||||
`;
|
||||
|
||||
expect(
|
||||
computeSkipWorkspaceSchemaCreation(
|
||||
query,
|
||||
parse(query),
|
||||
undefined,
|
||||
GENERATED_RESOLVERS,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true when all fields are generated workspace resolvers', () => {
|
||||
const query = `
|
||||
query {
|
||||
findManyCompanies { id name }
|
||||
}
|
||||
`;
|
||||
|
||||
expect(
|
||||
computeSkipWorkspaceSchemaCreation(
|
||||
query,
|
||||
parse(query),
|
||||
undefined,
|
||||
GENERATED_RESOLVERS,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for mixed queries', () => {
|
||||
const query = `
|
||||
query {
|
||||
findManyCompanies { id }
|
||||
currentWorkspace { id }
|
||||
}
|
||||
`;
|
||||
|
||||
expect(
|
||||
computeSkipWorkspaceSchemaCreation(
|
||||
query,
|
||||
parse(query),
|
||||
undefined,
|
||||
GENERATED_RESOLVERS,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for __schema introspection', () => {
|
||||
const query = `
|
||||
query {
|
||||
__schema { types { name } }
|
||||
}
|
||||
`;
|
||||
|
||||
expect(
|
||||
computeSkipWorkspaceSchemaCreation(
|
||||
query,
|
||||
parse(query),
|
||||
undefined,
|
||||
GENERATED_RESOLVERS,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for __type introspection', () => {
|
||||
const query = `
|
||||
query {
|
||||
__type(name: "Company") { name fields { name } }
|
||||
}
|
||||
`;
|
||||
|
||||
expect(
|
||||
computeSkipWorkspaceSchemaCreation(
|
||||
query,
|
||||
parse(query),
|
||||
undefined,
|
||||
GENERATED_RESOLVERS,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('should not treat __typename as introspection', () => {
|
||||
const query = `
|
||||
query {
|
||||
currentWorkspace { id __typename }
|
||||
}
|
||||
`;
|
||||
|
||||
expect(
|
||||
computeSkipWorkspaceSchemaCreation(
|
||||
query,
|
||||
parse(query),
|
||||
undefined,
|
||||
GENERATED_RESOLVERS,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true when no operation matches (no fields to check)', () => {
|
||||
const query = `
|
||||
query GetCompanies { findManyCompanies { id } }
|
||||
`;
|
||||
|
||||
expect(
|
||||
computeSkipWorkspaceSchemaCreation(
|
||||
query,
|
||||
parse(query),
|
||||
'NonExistent',
|
||||
GENERATED_RESOLVERS,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should respect operationName', () => {
|
||||
const query = `
|
||||
query CoreQuery {
|
||||
currentWorkspace { id }
|
||||
}
|
||||
query WorkspaceQuery {
|
||||
findManyCompanies { id }
|
||||
}
|
||||
`;
|
||||
|
||||
expect(
|
||||
computeSkipWorkspaceSchemaCreation(
|
||||
query,
|
||||
parse(query),
|
||||
'CoreQuery',
|
||||
GENERATED_RESOLVERS,
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
computeSkipWorkspaceSchemaCreation(
|
||||
query,
|
||||
parse(query),
|
||||
'WorkspaceQuery',
|
||||
GENERATED_RESOLVERS,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
+35
@@ -73,4 +73,39 @@ describe('graphQLExtractTopLevelFields', () => {
|
||||
|
||||
expect(fields).toEqual([]);
|
||||
});
|
||||
|
||||
it('should expand fragment spreads at the top level', () => {
|
||||
const query = `
|
||||
query {
|
||||
__schema { types { name } }
|
||||
...DataFragment
|
||||
}
|
||||
fragment DataFragment on Query {
|
||||
companies { id }
|
||||
}
|
||||
`;
|
||||
|
||||
const fields = graphQLExtractTopLevelFields(parse(query), undefined);
|
||||
|
||||
expect(fields).toHaveLength(2);
|
||||
expect(fields[0].name.value).toBe('__schema');
|
||||
expect(fields[1].name.value).toBe('companies');
|
||||
});
|
||||
|
||||
it('should expand inline fragments at the top level', () => {
|
||||
const query = `
|
||||
query {
|
||||
__schema { types { name } }
|
||||
... on Query {
|
||||
companies { id }
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const fields = graphQLExtractTopLevelFields(parse(query), undefined);
|
||||
|
||||
expect(fields).toHaveLength(2);
|
||||
expect(fields[0].name.value).toBe('__schema');
|
||||
expect(fields[1].name.value).toBe('companies');
|
||||
});
|
||||
});
|
||||
|
||||
-121
@@ -1,121 +0,0 @@
|
||||
import { parse } from 'graphql';
|
||||
|
||||
import { hasOnlyGeneratedWorkspaceResolvers } from 'src/engine/api/graphql/direct-execution/utils/has-only-generated-workspace-resolvers.util';
|
||||
|
||||
const GENERATED_RESOLVERS = new Set([
|
||||
'companies',
|
||||
'company',
|
||||
'createOneCompany',
|
||||
'people',
|
||||
'person',
|
||||
]);
|
||||
|
||||
describe('hasOnlyGeneratedWorkspaceResolvers', () => {
|
||||
it('should return true when all fields are generated workspace resolvers', () => {
|
||||
const query = `
|
||||
query {
|
||||
companies { id name }
|
||||
people { id email }
|
||||
}
|
||||
`;
|
||||
|
||||
expect(
|
||||
hasOnlyGeneratedWorkspaceResolvers(
|
||||
parse(query),
|
||||
undefined,
|
||||
GENERATED_RESOLVERS,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for a single generated workspace resolver', () => {
|
||||
const query = `
|
||||
query {
|
||||
company(filter: { id: { eq: "123" } }) { id }
|
||||
}
|
||||
`;
|
||||
|
||||
expect(
|
||||
hasOnlyGeneratedWorkspaceResolvers(
|
||||
parse(query),
|
||||
undefined,
|
||||
GENERATED_RESOLVERS,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when all fields are core resolvers', () => {
|
||||
const query = `
|
||||
query {
|
||||
search { id }
|
||||
getTimelineCalendarEventsFromOpportunityId { id }
|
||||
}
|
||||
`;
|
||||
|
||||
expect(
|
||||
hasOnlyGeneratedWorkspaceResolvers(
|
||||
parse(query),
|
||||
undefined,
|
||||
GENERATED_RESOLVERS,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for mixed queries', () => {
|
||||
const query = `
|
||||
query {
|
||||
companies { id }
|
||||
getTimelineCalendarEventsFromOpportunityId { id }
|
||||
}
|
||||
`;
|
||||
|
||||
expect(
|
||||
hasOnlyGeneratedWorkspaceResolvers(
|
||||
parse(query),
|
||||
undefined,
|
||||
GENERATED_RESOLVERS,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true when no operation matches (no fields to check)', () => {
|
||||
const query = `
|
||||
query GetCompanies { companies { id } }
|
||||
`;
|
||||
|
||||
expect(
|
||||
hasOnlyGeneratedWorkspaceResolvers(
|
||||
parse(query),
|
||||
'NonExistent',
|
||||
GENERATED_RESOLVERS,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should respect operationName', () => {
|
||||
const query = `
|
||||
query WorkspaceQuery {
|
||||
companies { id }
|
||||
}
|
||||
query CoreQuery {
|
||||
getTimelineCalendarEventsFromOpportunityId { id }
|
||||
}
|
||||
`;
|
||||
|
||||
expect(
|
||||
hasOnlyGeneratedWorkspaceResolvers(
|
||||
parse(query),
|
||||
'WorkspaceQuery',
|
||||
GENERATED_RESOLVERS,
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
hasOnlyGeneratedWorkspaceResolvers(
|
||||
parse(query),
|
||||
'CoreQuery',
|
||||
GENERATED_RESOLVERS,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
import { type DocumentNode } from 'graphql';
|
||||
|
||||
import { graphQLExtractTopLevelFields } from 'src/engine/api/graphql/direct-execution/utils/graphql-extract-top-level-fields.util';
|
||||
|
||||
const INTROSPECTION_FIELD_NAMES = new Set(['__schema', '__type']);
|
||||
|
||||
type TopLevelFieldsClassification = {
|
||||
hasIntrospectionFields: boolean;
|
||||
hasWorkspaceFields: boolean;
|
||||
hasCoreFields: boolean;
|
||||
};
|
||||
|
||||
export const classifyTopLevelFields = (
|
||||
document: DocumentNode,
|
||||
operationName: string | undefined,
|
||||
workspaceResolverNames: Set<string>,
|
||||
): TopLevelFieldsClassification => {
|
||||
const topLevelFields = graphQLExtractTopLevelFields(document, operationName);
|
||||
|
||||
let hasIntrospectionFields = false;
|
||||
let hasWorkspaceFields = false;
|
||||
let hasCoreFields = false;
|
||||
|
||||
for (const field of topLevelFields) {
|
||||
if (INTROSPECTION_FIELD_NAMES.has(field.name.value)) {
|
||||
hasIntrospectionFields = true;
|
||||
} else if (workspaceResolverNames.has(field.name.value)) {
|
||||
hasWorkspaceFields = true;
|
||||
} else {
|
||||
hasCoreFields = true;
|
||||
}
|
||||
}
|
||||
|
||||
return { hasIntrospectionFields, hasWorkspaceFields, hasCoreFields };
|
||||
};
|
||||
-27
@@ -1,27 +0,0 @@
|
||||
import { type DocumentNode } from 'graphql';
|
||||
|
||||
import { graphQLExtractTopLevelFields } from 'src/engine/api/graphql/direct-execution/utils/graphql-extract-top-level-fields.util';
|
||||
|
||||
const INTROSPECTION_PATTERN = /__schema|__type(?!name)/;
|
||||
|
||||
export const computeSkipWorkspaceSchemaCreation = (
|
||||
queryString: string,
|
||||
document: DocumentNode,
|
||||
operationName: string | undefined,
|
||||
generatedWorkspaceResolverNames: Set<string>,
|
||||
): boolean => {
|
||||
if (INTROSPECTION_PATTERN.test(queryString)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const topLevelFields = graphQLExtractTopLevelFields(document, operationName);
|
||||
|
||||
const hasCore = topLevelFields.some(
|
||||
(field) => !generatedWorkspaceResolverNames.has(field.name.value),
|
||||
);
|
||||
const hasGenerated = topLevelFields.some((field) =>
|
||||
generatedWorkspaceResolverNames.has(field.name.value),
|
||||
);
|
||||
|
||||
return !(hasCore && hasGenerated);
|
||||
};
|
||||
+27
-3
@@ -1,6 +1,7 @@
|
||||
import { type DocumentNode, type FieldNode, Kind } from 'graphql';
|
||||
|
||||
import { findOperationDefinition } from 'src/engine/api/graphql/direct-execution/utils/find-operation-definition.util';
|
||||
import { graphQLBuildFragmentMap } from 'src/engine/api/graphql/direct-execution/utils/graphql-build-fragment-map.util';
|
||||
|
||||
export const graphQLExtractTopLevelFields = (
|
||||
document: DocumentNode,
|
||||
@@ -12,7 +13,30 @@ export const graphQLExtractTopLevelFields = (
|
||||
return [];
|
||||
}
|
||||
|
||||
return operationDefinition.selectionSet.selections.filter(
|
||||
(selection): selection is FieldNode => selection.kind === Kind.FIELD,
|
||||
);
|
||||
const fragmentMap = graphQLBuildFragmentMap(document);
|
||||
const fields: FieldNode[] = [];
|
||||
|
||||
for (const selection of operationDefinition.selectionSet.selections) {
|
||||
if (selection.kind === Kind.FIELD) {
|
||||
fields.push(selection);
|
||||
} else if (selection.kind === Kind.FRAGMENT_SPREAD) {
|
||||
const fragment = fragmentMap.get(selection.name.value);
|
||||
|
||||
if (fragment) {
|
||||
for (const fragmentSelection of fragment.selectionSet.selections) {
|
||||
if (fragmentSelection.kind === Kind.FIELD) {
|
||||
fields.push(fragmentSelection);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (selection.kind === Kind.INLINE_FRAGMENT) {
|
||||
for (const inlineSelection of selection.selectionSet.selections) {
|
||||
if (inlineSelection.kind === Kind.FIELD) {
|
||||
fields.push(inlineSelection);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return fields;
|
||||
};
|
||||
|
||||
-15
@@ -1,15 +0,0 @@
|
||||
import { type DocumentNode } from 'graphql';
|
||||
|
||||
import { graphQLExtractTopLevelFields } from 'src/engine/api/graphql/direct-execution/utils/graphql-extract-top-level-fields.util';
|
||||
|
||||
export const hasOnlyGeneratedWorkspaceResolvers = (
|
||||
document: DocumentNode,
|
||||
operationName: string | undefined,
|
||||
generatedWorkspaceResolverNames: Set<string>,
|
||||
): boolean => {
|
||||
const topLevelFields = graphQLExtractTopLevelFields(document, operationName);
|
||||
|
||||
return topLevelFields.every((field) =>
|
||||
generatedWorkspaceResolverNames.has(field.name.value),
|
||||
);
|
||||
};
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { ScalarsExplorerService } from 'src/engine/api/graphql/services/scalars-explorer.service';
|
||||
import { WorkspaceSchemaBuilderModule } from 'src/engine/api/graphql/workspace-schema-builder/workspace-schema-builder.module';
|
||||
import { WorkspaceGraphqlSchemaSDLService } from 'src/engine/api/graphql/workspace-graphql-schema-sdl/workspace-graphql-schema-sdl.service';
|
||||
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
|
||||
import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module';
|
||||
import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module';
|
||||
import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
WorkspaceSchemaBuilderModule,
|
||||
WorkspaceCacheStorageModule,
|
||||
WorkspaceManyOrAllFlatEntityMapsCacheModule,
|
||||
FeatureFlagModule,
|
||||
DataSourceModule,
|
||||
],
|
||||
providers: [WorkspaceGraphqlSchemaSDLService, ScalarsExplorerService],
|
||||
exports: [WorkspaceGraphqlSchemaSDLService],
|
||||
})
|
||||
export class WorkspaceGraphqlSchemaSDLModule {}
|
||||
+232
@@ -0,0 +1,232 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
import { printSchema } from 'graphql';
|
||||
import { FeatureFlagKey } from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { ScalarsExplorerService } from 'src/engine/api/graphql/services/scalars-explorer.service';
|
||||
import { WorkspaceGraphQLSchemaGenerator } from 'src/engine/api/graphql/workspace-schema-builder/workspace-graphql-schema.factory';
|
||||
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
|
||||
import { FlatWorkspace } from 'src/engine/core-modules/workspace/types/flat-workspace.type';
|
||||
import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service';
|
||||
import {
|
||||
FlatEntityMapsException,
|
||||
FlatEntityMapsExceptionCode,
|
||||
} from 'src/engine/metadata-modules/flat-entity/exceptions/flat-entity-maps.exception';
|
||||
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
|
||||
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
|
||||
import { getSubFlatEntityMapsByApplicationIdsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/get-sub-flat-entity-maps-by-application-ids-or-throw.util';
|
||||
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
|
||||
import { type FlatIndexMetadata } from 'src/engine/metadata-modules/flat-index-metadata/types/flat-index-metadata.type';
|
||||
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
|
||||
import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service';
|
||||
import { TWENTY_STANDARD_APPLICATION } from 'src/engine/workspace-manager/twenty-standard-application/constants/twenty-standard-applications';
|
||||
|
||||
export type WorkspaceGraphqlSchemaSDLResult = {
|
||||
sdl: string;
|
||||
usedScalarNames: string[];
|
||||
flatObjectMetadataMaps: FlatEntityMaps<FlatObjectMetadata>;
|
||||
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class WorkspaceGraphqlSchemaSDLService {
|
||||
constructor(
|
||||
private readonly scalarsExplorerService: ScalarsExplorerService,
|
||||
private readonly workspaceGraphQLSchemaGenerator: WorkspaceGraphQLSchemaGenerator,
|
||||
private readonly workspaceCacheStorageService: WorkspaceCacheStorageService,
|
||||
private readonly workspaceManyOrAllFlatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService,
|
||||
private readonly featureFlagService: FeatureFlagService,
|
||||
private readonly dataSourceService: DataSourceService,
|
||||
) {}
|
||||
|
||||
async getOrComputeSchemaSDL(
|
||||
workspace: FlatWorkspace,
|
||||
applicationId?: string,
|
||||
): Promise<WorkspaceGraphqlSchemaSDLResult | null> {
|
||||
const isDataSourceMigrated = await this.featureFlagService.isFeatureEnabled(
|
||||
FeatureFlagKey.IS_DATASOURCE_MIGRATED,
|
||||
workspace.id,
|
||||
);
|
||||
|
||||
const hasSchema = isDataSourceMigrated
|
||||
? isNonEmptyString(workspace.databaseSchema)
|
||||
: (
|
||||
await this.dataSourceService.getDataSourcesMetadataFromWorkspaceId(
|
||||
workspace.id,
|
||||
)
|
||||
).length > 0;
|
||||
|
||||
if (!hasSchema) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const {
|
||||
flatObjectMetadataMaps: allFlatObjectMetadataMaps,
|
||||
flatFieldMetadataMaps: allFlatFieldMetadataMaps,
|
||||
flatIndexMaps: allFlatIndexMaps,
|
||||
flatApplicationMaps,
|
||||
} = await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
|
||||
{
|
||||
workspaceId: workspace.id,
|
||||
flatMapsKeys: [
|
||||
'flatObjectMetadataMaps',
|
||||
'flatFieldMetadataMaps',
|
||||
'flatIndexMaps',
|
||||
'flatApplicationMaps',
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
if (!isDefined(allFlatObjectMetadataMaps)) {
|
||||
throw new FlatEntityMapsException(
|
||||
'Object metadata collection not found',
|
||||
FlatEntityMapsExceptionCode.ENTITY_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
if (!isDefined(allFlatFieldMetadataMaps)) {
|
||||
throw new FlatEntityMapsException(
|
||||
'Field metadata collection not found',
|
||||
FlatEntityMapsExceptionCode.ENTITY_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
let flatObjectMetadataMaps = allFlatObjectMetadataMaps;
|
||||
let flatFieldMetadataMaps = allFlatFieldMetadataMaps;
|
||||
let flatIndexMaps = allFlatIndexMaps;
|
||||
|
||||
if (isDefined(applicationId)) {
|
||||
const twentyStandardApplicationId =
|
||||
flatApplicationMaps?.idByUniversalIdentifier[
|
||||
TWENTY_STANDARD_APPLICATION.universalIdentifier
|
||||
];
|
||||
|
||||
const applicationIds = isDefined(twentyStandardApplicationId)
|
||||
? [twentyStandardApplicationId, applicationId]
|
||||
: [applicationId];
|
||||
|
||||
flatObjectMetadataMaps = this.filterFlatEntityMapsByApplicationIds(
|
||||
allFlatObjectMetadataMaps,
|
||||
applicationIds,
|
||||
);
|
||||
flatFieldMetadataMaps = this.filterFlatEntityMapsByApplicationIds(
|
||||
allFlatFieldMetadataMaps,
|
||||
applicationIds,
|
||||
);
|
||||
|
||||
flatObjectMetadataMaps =
|
||||
this.reconcileObjectFieldIdsWithFilteredFieldMaps(
|
||||
flatObjectMetadataMaps,
|
||||
flatFieldMetadataMaps,
|
||||
);
|
||||
|
||||
if (isDefined(allFlatIndexMaps)) {
|
||||
flatIndexMaps = this.filterFlatEntityMapsByApplicationIds(
|
||||
allFlatIndexMaps,
|
||||
applicationIds,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let metadataVersion =
|
||||
await this.workspaceCacheStorageService.getMetadataVersion(workspace.id);
|
||||
|
||||
if (!isDefined(metadataVersion)) {
|
||||
metadataVersion = isDefined(workspace.metadataVersion)
|
||||
? workspace.metadataVersion
|
||||
: 0;
|
||||
await this.workspaceCacheStorageService.setMetadataVersion(
|
||||
workspace.id,
|
||||
metadataVersion,
|
||||
);
|
||||
}
|
||||
|
||||
let sdl = await this.workspaceCacheStorageService.getGraphQLTypeDefs(
|
||||
workspace.id,
|
||||
metadataVersion,
|
||||
applicationId,
|
||||
);
|
||||
let usedScalarNames =
|
||||
await this.workspaceCacheStorageService.getGraphQLUsedScalarNames(
|
||||
workspace.id,
|
||||
metadataVersion,
|
||||
applicationId,
|
||||
);
|
||||
|
||||
if (!sdl || !usedScalarNames) {
|
||||
const autoGeneratedSchema =
|
||||
await this.workspaceGraphQLSchemaGenerator.generateSchema({
|
||||
flatObjectMetadataMaps,
|
||||
flatFieldMetadataMaps,
|
||||
flatIndexMaps,
|
||||
});
|
||||
|
||||
usedScalarNames =
|
||||
this.scalarsExplorerService.getUsedScalarNames(autoGeneratedSchema);
|
||||
sdl = printSchema(autoGeneratedSchema);
|
||||
|
||||
await this.workspaceCacheStorageService.setGraphQLTypeDefs(
|
||||
workspace.id,
|
||||
metadataVersion,
|
||||
sdl,
|
||||
applicationId,
|
||||
);
|
||||
await this.workspaceCacheStorageService.setGraphQLUsedScalarNames(
|
||||
workspace.id,
|
||||
metadataVersion,
|
||||
usedScalarNames,
|
||||
applicationId,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
sdl,
|
||||
usedScalarNames,
|
||||
flatObjectMetadataMaps,
|
||||
flatFieldMetadataMaps,
|
||||
};
|
||||
}
|
||||
|
||||
private reconcileObjectFieldIdsWithFilteredFieldMaps(
|
||||
flatObjectMetadataMaps: FlatEntityMaps<FlatObjectMetadata>,
|
||||
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>,
|
||||
): FlatEntityMaps<FlatObjectMetadata> {
|
||||
const filteredFieldIds = new Set(
|
||||
Object.keys(flatFieldMetadataMaps.universalIdentifierById),
|
||||
);
|
||||
|
||||
const reconciledByUniversalIdentifier: Partial<
|
||||
Record<string, FlatObjectMetadata>
|
||||
> = {};
|
||||
|
||||
for (const [universalId, object] of Object.entries(
|
||||
flatObjectMetadataMaps.byUniversalIdentifier,
|
||||
)) {
|
||||
if (!isDefined(object)) continue;
|
||||
|
||||
reconciledByUniversalIdentifier[universalId] = {
|
||||
...object,
|
||||
fieldIds: object.fieldIds.filter((id) => filteredFieldIds.has(id)),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...flatObjectMetadataMaps,
|
||||
byUniversalIdentifier: reconciledByUniversalIdentifier,
|
||||
};
|
||||
}
|
||||
|
||||
private filterFlatEntityMapsByApplicationIds<
|
||||
T extends FlatObjectMetadata | FlatFieldMetadata | FlatIndexMetadata,
|
||||
>(
|
||||
flatEntityMaps: FlatEntityMaps<T>,
|
||||
applicationIds: string[],
|
||||
): FlatEntityMaps<T> {
|
||||
return getSubFlatEntityMapsByApplicationIdsOrThrow({
|
||||
applicationIds,
|
||||
flatEntityMaps,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,45 +1,25 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { makeExecutableSchema } from '@graphql-tools/schema';
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
import { GraphQLSchema, printSchema } from 'graphql';
|
||||
import { GraphQLSchema } from 'graphql';
|
||||
import { gql } from 'graphql-tag';
|
||||
import { FeatureFlagKey } from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { ScalarsExplorerService } from 'src/engine/api/graphql/services/scalars-explorer.service';
|
||||
import { WorkspaceGraphqlSchemaSDLService } from 'src/engine/api/graphql/workspace-graphql-schema-sdl/workspace-graphql-schema-sdl.service';
|
||||
import { workspaceResolverBuilderMethodNames } from 'src/engine/api/graphql/workspace-resolver-builder/factories/factories';
|
||||
import { WorkspaceResolverFactory } from 'src/engine/api/graphql/workspace-resolver-builder/workspace-resolver.factory';
|
||||
import { WorkspaceGraphQLSchemaGenerator } from 'src/engine/api/graphql/workspace-schema-builder/workspace-graphql-schema.factory';
|
||||
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
|
||||
import { MetricsService } from 'src/engine/core-modules/metrics/metrics.service';
|
||||
import { MetricsKeys } from 'src/engine/core-modules/metrics/types/metrics-keys.type';
|
||||
import { FlatWorkspace } from 'src/engine/core-modules/workspace/types/flat-workspace.type';
|
||||
import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service';
|
||||
import {
|
||||
FlatEntityMapsException,
|
||||
FlatEntityMapsExceptionCode,
|
||||
} from 'src/engine/metadata-modules/flat-entity/exceptions/flat-entity-maps.exception';
|
||||
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
|
||||
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
|
||||
import { getSubFlatEntityMapsByApplicationIdsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/get-sub-flat-entity-maps-by-application-ids-or-throw.util';
|
||||
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
|
||||
import { type FlatIndexMetadata } from 'src/engine/metadata-modules/flat-index-metadata/types/flat-index-metadata.type';
|
||||
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
|
||||
import { buildObjectIdByNameMaps } from 'src/engine/metadata-modules/flat-object-metadata/utils/build-object-id-by-name-maps.util';
|
||||
import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service';
|
||||
import { TWENTY_STANDARD_APPLICATION } from 'src/engine/workspace-manager/twenty-standard-application/constants/twenty-standard-applications';
|
||||
|
||||
@Injectable()
|
||||
export class WorkspaceSchemaFactory {
|
||||
constructor(
|
||||
private readonly scalarsExplorerService: ScalarsExplorerService,
|
||||
private readonly workspaceGraphQLSchemaGenerator: WorkspaceGraphQLSchemaGenerator,
|
||||
private readonly workspaceResolverFactory: WorkspaceResolverFactory,
|
||||
private readonly workspaceCacheStorageService: WorkspaceCacheStorageService,
|
||||
private readonly workspaceManyOrAllFlatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService,
|
||||
private readonly featureFlagService: FeatureFlagService,
|
||||
private readonly dataSourceService: DataSourceService,
|
||||
private readonly workspaceGraphqlSchemaSDLService: WorkspaceGraphqlSchemaSDLService,
|
||||
private readonly metricsService: MetricsService,
|
||||
) {}
|
||||
|
||||
@@ -47,146 +27,27 @@ export class WorkspaceSchemaFactory {
|
||||
workspace: FlatWorkspace,
|
||||
applicationId?: string,
|
||||
): Promise<GraphQLSchema> {
|
||||
const isDataSourceMigrated = await this.featureFlagService.isFeatureEnabled(
|
||||
FeatureFlagKey.IS_DATASOURCE_MIGRATED,
|
||||
workspace.id,
|
||||
);
|
||||
const schemaSDLResult =
|
||||
await this.workspaceGraphqlSchemaSDLService.getOrComputeSchemaSDL(
|
||||
workspace,
|
||||
applicationId,
|
||||
);
|
||||
|
||||
const hasSchema = isDataSourceMigrated
|
||||
? isNonEmptyString(workspace.databaseSchema)
|
||||
: (
|
||||
await this.dataSourceService.getDataSourcesMetadataFromWorkspaceId(
|
||||
workspace.id,
|
||||
)
|
||||
).length > 0;
|
||||
|
||||
if (!hasSchema) {
|
||||
if (!isDefined(schemaSDLResult)) {
|
||||
return new GraphQLSchema({});
|
||||
}
|
||||
|
||||
const {
|
||||
flatObjectMetadataMaps: allFlatObjectMetadataMaps,
|
||||
flatFieldMetadataMaps: allFlatFieldMetadataMaps,
|
||||
flatIndexMaps: allFlatIndexMaps,
|
||||
flatApplicationMaps,
|
||||
} = await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
|
||||
{
|
||||
workspaceId: workspace.id,
|
||||
flatMapsKeys: [
|
||||
'flatObjectMetadataMaps',
|
||||
'flatFieldMetadataMaps',
|
||||
'flatIndexMaps',
|
||||
'flatApplicationMaps',
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
if (!isDefined(allFlatObjectMetadataMaps)) {
|
||||
throw new FlatEntityMapsException(
|
||||
'Object metadata collection not found',
|
||||
FlatEntityMapsExceptionCode.ENTITY_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
if (!isDefined(allFlatFieldMetadataMaps)) {
|
||||
throw new FlatEntityMapsException(
|
||||
'Field metadata collection not found',
|
||||
FlatEntityMapsExceptionCode.ENTITY_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
let flatObjectMetadataMaps = allFlatObjectMetadataMaps;
|
||||
let flatFieldMetadataMaps = allFlatFieldMetadataMaps;
|
||||
let flatIndexMaps = allFlatIndexMaps;
|
||||
|
||||
if (isDefined(applicationId)) {
|
||||
const twentyStandardApplicationId =
|
||||
flatApplicationMaps?.idByUniversalIdentifier[
|
||||
TWENTY_STANDARD_APPLICATION.universalIdentifier
|
||||
];
|
||||
|
||||
const applicationIds = isDefined(twentyStandardApplicationId)
|
||||
? [twentyStandardApplicationId, applicationId]
|
||||
: [applicationId];
|
||||
|
||||
flatObjectMetadataMaps = this.filterFlatEntityMapsByApplicationIds(
|
||||
allFlatObjectMetadataMaps,
|
||||
applicationIds,
|
||||
);
|
||||
flatFieldMetadataMaps = this.filterFlatEntityMapsByApplicationIds(
|
||||
allFlatFieldMetadataMaps,
|
||||
applicationIds,
|
||||
);
|
||||
|
||||
flatObjectMetadataMaps =
|
||||
this.reconcileObjectFieldIdsWithFilteredFieldMaps(
|
||||
flatObjectMetadataMaps,
|
||||
flatFieldMetadataMaps,
|
||||
);
|
||||
|
||||
if (isDefined(allFlatIndexMaps)) {
|
||||
flatIndexMaps = this.filterFlatEntityMapsByApplicationIds(
|
||||
allFlatIndexMaps,
|
||||
applicationIds,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let metadataVersion =
|
||||
await this.workspaceCacheStorageService.getMetadataVersion(workspace.id);
|
||||
|
||||
if (!isDefined(metadataVersion)) {
|
||||
metadataVersion = isDefined(workspace.metadataVersion)
|
||||
? workspace.metadataVersion
|
||||
: 0;
|
||||
await this.workspaceCacheStorageService.setMetadataVersion(
|
||||
workspace.id,
|
||||
metadataVersion,
|
||||
);
|
||||
}
|
||||
sdl,
|
||||
usedScalarNames,
|
||||
flatObjectMetadataMaps,
|
||||
flatFieldMetadataMaps,
|
||||
} = schemaSDLResult;
|
||||
|
||||
const { idByNameSingular } = buildObjectIdByNameMaps(
|
||||
flatObjectMetadataMaps,
|
||||
);
|
||||
|
||||
let typeDefs = await this.workspaceCacheStorageService.getGraphQLTypeDefs(
|
||||
workspace.id,
|
||||
metadataVersion,
|
||||
applicationId,
|
||||
);
|
||||
let usedScalarNames =
|
||||
await this.workspaceCacheStorageService.getGraphQLUsedScalarNames(
|
||||
workspace.id,
|
||||
metadataVersion,
|
||||
applicationId,
|
||||
);
|
||||
|
||||
if (!typeDefs || !usedScalarNames) {
|
||||
const autoGeneratedSchema =
|
||||
await this.workspaceGraphQLSchemaGenerator.generateSchema({
|
||||
flatObjectMetadataMaps,
|
||||
flatFieldMetadataMaps,
|
||||
flatIndexMaps,
|
||||
});
|
||||
|
||||
usedScalarNames =
|
||||
this.scalarsExplorerService.getUsedScalarNames(autoGeneratedSchema);
|
||||
typeDefs = printSchema(autoGeneratedSchema);
|
||||
|
||||
await this.workspaceCacheStorageService.setGraphQLTypeDefs(
|
||||
workspace.id,
|
||||
metadataVersion,
|
||||
typeDefs,
|
||||
applicationId,
|
||||
);
|
||||
await this.workspaceCacheStorageService.setGraphQLUsedScalarNames(
|
||||
workspace.id,
|
||||
metadataVersion,
|
||||
usedScalarNames,
|
||||
applicationId,
|
||||
);
|
||||
}
|
||||
|
||||
const autoGeneratedResolvers = await this.workspaceResolverFactory.create(
|
||||
flatObjectMetadataMaps,
|
||||
flatFieldMetadataMaps,
|
||||
@@ -198,7 +59,7 @@ export class WorkspaceSchemaFactory {
|
||||
|
||||
const executableSchema = makeExecutableSchema({
|
||||
typeDefs: gql`
|
||||
${typeDefs}
|
||||
${sdl}
|
||||
`,
|
||||
resolvers: {
|
||||
...scalarsResolvers,
|
||||
@@ -213,45 +74,4 @@ export class WorkspaceSchemaFactory {
|
||||
|
||||
return executableSchema;
|
||||
}
|
||||
|
||||
private reconcileObjectFieldIdsWithFilteredFieldMaps(
|
||||
flatObjectMetadataMaps: FlatEntityMaps<FlatObjectMetadata>,
|
||||
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>,
|
||||
): FlatEntityMaps<FlatObjectMetadata> {
|
||||
const filteredFieldIds = new Set(
|
||||
Object.keys(flatFieldMetadataMaps.universalIdentifierById),
|
||||
);
|
||||
|
||||
const reconciledByUniversalIdentifier: Partial<
|
||||
Record<string, FlatObjectMetadata>
|
||||
> = {};
|
||||
|
||||
for (const [universalId, object] of Object.entries(
|
||||
flatObjectMetadataMaps.byUniversalIdentifier,
|
||||
)) {
|
||||
if (!isDefined(object)) continue;
|
||||
|
||||
reconciledByUniversalIdentifier[universalId] = {
|
||||
...object,
|
||||
fieldIds: object.fieldIds.filter((id) => filteredFieldIds.has(id)),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...flatObjectMetadataMaps,
|
||||
byUniversalIdentifier: reconciledByUniversalIdentifier,
|
||||
};
|
||||
}
|
||||
|
||||
private filterFlatEntityMapsByApplicationIds<
|
||||
T extends FlatObjectMetadata | FlatFieldMetadata | FlatIndexMetadata,
|
||||
>(
|
||||
flatEntityMaps: FlatEntityMaps<T>,
|
||||
applicationIds: string[],
|
||||
): FlatEntityMaps<T> {
|
||||
return getSubFlatEntityMapsByApplicationIdsOrThrow({
|
||||
applicationIds,
|
||||
flatEntityMaps,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -95,7 +95,7 @@ describe('RefreshTokenService', () => {
|
||||
jest
|
||||
.spyOn(appTokenRepository, 'findOneBy')
|
||||
.mockResolvedValue(mockAppToken);
|
||||
jest.spyOn(userRepository, 'findOne').mockResolvedValue(mockUser);
|
||||
jest.spyOn(userRepository, 'findOneBy').mockResolvedValue(mockUser);
|
||||
jest.spyOn(twentyConfigService, 'get').mockReturnValue('1h');
|
||||
|
||||
const result = await service.verifyRefreshToken(mockToken);
|
||||
@@ -204,7 +204,7 @@ describe('RefreshTokenService', () => {
|
||||
|
||||
const user = { id: userId } as UserEntity;
|
||||
|
||||
jest.spyOn(userRepository, 'findOne').mockResolvedValue(user);
|
||||
jest.spyOn(userRepository, 'findOneBy').mockResolvedValue(user);
|
||||
|
||||
const out = await service.verifyRefreshToken(refreshToken);
|
||||
|
||||
|
||||
+5
-19
@@ -67,9 +67,8 @@ export class RefreshTokenService {
|
||||
);
|
||||
}
|
||||
|
||||
const user = await this.userRepository.findOne({
|
||||
where: { id: jwtPayload.sub },
|
||||
relations: ['appTokens'],
|
||||
const user = await this.userRepository.findOneBy({
|
||||
id: jwtPayload.sub,
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
@@ -84,23 +83,10 @@ export class RefreshTokenService {
|
||||
token.revokedAt.getTime() <= Date.now() - ms(reuseGracePeriod);
|
||||
|
||||
if (wasRevokedBeforeGracePeriod) {
|
||||
// Token was revoked long ago and is being reused -- suspicious.
|
||||
// Revoke all user refresh tokens as a safety measure.
|
||||
await Promise.all(
|
||||
user.appTokens.map(async ({ id, type }) => {
|
||||
if (type === AppTokenType.RefreshToken) {
|
||||
await this.appTokenRepository.update(
|
||||
{ id },
|
||||
{
|
||||
revokedAt: new Date(),
|
||||
},
|
||||
);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
// Reject the stale token but don't revoke all tokens — the most
|
||||
// common cause is a lost renewal response, not actual token theft.
|
||||
throw new AuthException(
|
||||
'Suspicious activity detected, this refresh token has been revoked. All tokens have been revoked.',
|
||||
'This refresh token has been revoked.',
|
||||
AuthExceptionCode.FORBIDDEN_EXCEPTION,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -35,4 +35,5 @@ export enum MetricsKeys {
|
||||
JobFailed = 'job/failed',
|
||||
JobWaiting = 'job/waiting',
|
||||
GraphqlSchemaBuild = 'graphql/schema-build',
|
||||
GraphqlIntrospectionDirectExecution = 'graphql-introspection/direct-execution',
|
||||
}
|
||||
|
||||
+1
@@ -32,6 +32,7 @@ export type ViewFieldOverrides = {
|
||||
@Index('IDX_VIEW_FIELD_WORKSPACE_ID_VIEW_ID', ['workspaceId', 'viewId'])
|
||||
@Index('IDX_VIEW_FIELD_VIEW_ID', ['viewId'])
|
||||
@Index('IDX_VIEW_FIELD_FIELD_METADATA_ID', ['fieldMetadataId'])
|
||||
@Index('IDX_VIEW_FIELD_VIEW_FIELD_GROUP_ID', ['viewFieldGroupId'])
|
||||
@Index(
|
||||
'IDX_VIEW_FIELD_FIELD_METADATA_ID_VIEW_ID_UNIQUE',
|
||||
['fieldMetadataId', 'viewId'],
|
||||
|
||||
Reference in New Issue
Block a user