Compare commits

...
Author SHA1 Message Date
Devessier 74e3dba98f Merge remote-tracking branch 'origin/main' into rpl-front-components 2026-04-02 14:25:41 +02:00
Félix MalfaitGitHubclaude[bot] <41898282+claude[bot]@users.noreply.github.com>
2ebff5f4d7 fix: harden token renewal and soften refresh token revocation (#19175)
## Summary
- **Apollo factory** (`apollo.factory.ts`): Bail early with `EMPTY` when
no token pair exists so no request is forwarded without credentials.
Propagate renewal success/failure as a boolean so failed renewals stop
the operation chain instead of forwarding with stale tokens.
- **Refresh token service** (`refresh-token.service.ts`): When a revoked
refresh token is reused past the grace period, reject only that token
instead of mass-revoking all user tokens. The most common cause is a
lost renewal response (e.g. navigation during refresh), not actual token
theft. This eliminates the "Suspicious activity detected" errors users
were seeing. Also switches to `findOneBy` since the `appTokens` relation
is no longer needed.

## Test plan
- [x] `refresh-token.service.spec.ts` — all 7 tests pass
- [ ] Verify login flow: sign in from `app.localhost`, get redirected to
workspace subdomain without "Suspicious activity" errors
- [ ] Verify token renewal: let access token expire, confirm silent
renewal works and operations resume
- [ ] Verify concurrent tabs: open multiple tabs, let tokens expire,
confirm no mass revocation cascade

Made with [Cursor](https://cursor.com)

---------

Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
2026-04-02 12:01:31 +00:00
Charles BochetandGitHub b8e7179a85 Add missing index on viewField.viewFieldGroupId (#19253)
## Summary

- Adds an index on `viewField.viewFieldGroupId` to fix a ~28s `DELETE
FROM core.viewFieldGroup WHERE workspaceId = $1` query observed in
production
- The slow query is triggered during workspace hard deletion
(`deleteWorkspaceSyncableMetadataEntities` in `workspace.service.ts`)
- Root cause: the FK constraint `viewField.viewFieldGroupId →
viewFieldGroup.id` with `ON DELETE SET NULL` forces PostgreSQL to
sequentially scan the entire `viewField` table for every deleted
`viewFieldGroup` row, because no index exists on the referencing column
- Uses `CREATE INDEX CONCURRENTLY` in the migration to avoid locking the
table on production
2026-04-02 11:57:27 +00:00
Baptiste DevessierandGitHub b1a7155431 Disable edition for record page layouts disabled in the side panel (#19248)
## Before


https://github.com/user-attachments/assets/8e5151d8-7e90-420c-a826-b6e670c68000

## After


https://github.com/user-attachments/assets/a230bc46-5991-4972-94d1-9fffe894ff4a

Fixes
https://discord.com/channels/1130383047699738754/1488106565117673513
2026-04-02 11:51:44 +00:00
EtienneandGitHub a303d9ca1b Gql direct execution - Handle introspection queries (#19219)
### Context
GraphQL introspection queries (__schema, __type) were going through the
full Yoga server pipeline, which forces a complete workspace schema
build, loading flat metadata maps, building all GraphQL types, wiring
all resolver factories, and calling makeExecutableSchema. This is
expensive, even though introspection only needs the type structure and
zero resolver execution.

A new WorkspaceGraphqlSchemaSDLService extracts the SDL computation that
was previously embedded inside WorkspaceSchemaFactory.

From `direct-execution.service.ts` : 
- `buildSchema(sdl)` reconstructs a resolver-free GraphQLSchema from the
SDL
     - pure CPU, not cached, not sure it worths it ?

- `execute({ schema, document, variableValues })` from graphql-js,
introspection is answered entirely by the graphql-js runtime from type
metadata, no resolver execution needed


#### Nice to do ?
- Use new cache service for typeDefs ?


### Renaming bonus: `typeDefs` → `sdl`

`typeDefs` is an Apollo/graphql-tools convention. It's the parameter
name in
`makeExecutableSchema({ typeDefs, resolvers })`, not a native GraphQL
spec term.

In proper GraphQL semantics, what this service produces is the **SDL**
(Schema
Definition Language): the official term for the string representation of
a schema.
`printSchema()` produces it, `buildSchema()` consumes it.

##### Why the distinction matters

- **Type definitions** implies partial type declarations (objects,
scalars, enums…)
- **Schema SDL** conveys a *complete* schema document: all types
**plus** the root
operation types (`Query`, `Mutation`) which is exactly what
`printSchema(schema)`
  produces
2026-04-02 11:50:18 +00:00
Devessier d225d132f9 Merge remote-tracking branch 'origin/main' into rpl-front-components 2026-03-25 14:38:41 +01:00
Devessier 020000b3c5 fixup 2026-03-25 14:37:44 +01:00
Devessier 0a94f93f0c feat: scaffolding 2026-03-25 14:37:41 +01:00
31 changed files with 910 additions and 655 deletions
@@ -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 = ({
@@ -6,6 +6,7 @@ export const FIND_MANY_FRONT_COMPONENTS = gql`
id
name
applicationId
isHeadless
}
}
`;
@@ -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>
@@ -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>
);
};
@@ -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 };
};
@@ -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,
};
};
@@ -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>
@@ -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);
}
@@ -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,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],
@@ -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,
@@ -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;
@@ -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,
});
});
});
@@ -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);
});
});
@@ -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');
});
});
@@ -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);
});
});
@@ -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 };
};
@@ -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);
};
@@ -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;
};
@@ -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),
);
};
@@ -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 {}
@@ -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,
});
}
}
@@ -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);
@@ -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',
}
@@ -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'],