diff --git a/packages/twenty-front/jest.config.mjs b/packages/twenty-front/jest.config.mjs index e068787970..4f30411717 100644 --- a/packages/twenty-front/jest.config.mjs +++ b/packages/twenty-front/jest.config.mjs @@ -61,7 +61,7 @@ const jestConfig = { extensionsToTreatAsEsm: ['.ts', '.tsx'], coverageThreshold: { global: { - statements: 48.8, + statements: 48.5, lines: 47.0, functions: 39.5, }, diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index be0483f4d5..fc8ffad795 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -2404,6 +2404,7 @@ export type MetadataEvent = { properties: ObjectRecordEventProperties; recordId: Scalars['String']; type: MetadataEventAction; + updatedCollectionHash?: Maybe; }; /** Metadata Event Action */ @@ -2421,7 +2422,7 @@ export type MetadataEventWithQueryIds = { export type MinimalMetadata = { __typename?: 'MinimalMetadata'; - metadataVersion: Scalars['Int']; + collectionHashes: Scalars['JSON']; objectMetadataItems: Array; views: Array; }; diff --git a/packages/twenty-front/src/modules/browser-event/types/MetadataOperationBrowserEventDetail.ts b/packages/twenty-front/src/modules/browser-event/types/MetadataOperationBrowserEventDetail.ts index cff91e9749..68e4c7fd44 100644 --- a/packages/twenty-front/src/modules/browser-event/types/MetadataOperationBrowserEventDetail.ts +++ b/packages/twenty-front/src/modules/browser-event/types/MetadataOperationBrowserEventDetail.ts @@ -6,4 +6,5 @@ export type MetadataOperationBrowserEventDetail< > = { metadataName: AllMetadataName; operation: MetadataOperation; + updatedCollectionHash?: string; }; diff --git a/packages/twenty-front/src/modules/metadata-store/effect-components/FieldMetadataSSEEffect.tsx b/packages/twenty-front/src/modules/metadata-store/effect-components/FieldMetadataSSEEffect.tsx index f44521b224..ee6da8a242 100644 --- a/packages/twenty-front/src/modules/metadata-store/effect-components/FieldMetadataSSEEffect.tsx +++ b/packages/twenty-front/src/modules/metadata-store/effect-components/FieldMetadataSSEEffect.tsx @@ -24,6 +24,7 @@ export const FieldMetadataSSEEffect = () => { store, 'fieldMetadataItems', eventDetail.operation, + eventDetail.updatedCollectionHash, ); }, }); diff --git a/packages/twenty-front/src/modules/metadata-store/effect-components/NavigationMenuItemSSEEffect.tsx b/packages/twenty-front/src/modules/metadata-store/effect-components/NavigationMenuItemSSEEffect.tsx index ac84c01155..2ad80901d0 100644 --- a/packages/twenty-front/src/modules/metadata-store/effect-components/NavigationMenuItemSSEEffect.tsx +++ b/packages/twenty-front/src/modules/metadata-store/effect-components/NavigationMenuItemSSEEffect.tsx @@ -34,6 +34,7 @@ export const NavigationMenuItemSSEEffect = () => { store, 'navigationMenuItems', eventDetail.operation, + eventDetail.updatedCollectionHash, ); const result = await client.query({ diff --git a/packages/twenty-front/src/modules/metadata-store/effect-components/ObjectMetadataItemSSEEffect.tsx b/packages/twenty-front/src/modules/metadata-store/effect-components/ObjectMetadataItemSSEEffect.tsx index ef0ce959d8..142779f6d5 100644 --- a/packages/twenty-front/src/modules/metadata-store/effect-components/ObjectMetadataItemSSEEffect.tsx +++ b/packages/twenty-front/src/modules/metadata-store/effect-components/ObjectMetadataItemSSEEffect.tsx @@ -31,6 +31,7 @@ export const ObjectMetadataItemSSEEffect = () => { store, 'objectMetadataItems', eventDetail.operation, + eventDetail.updatedCollectionHash, ); const navigationMenuItemsResult = await client.query({ diff --git a/packages/twenty-front/src/modules/metadata-store/effect-components/PageLayoutSSEEffect.tsx b/packages/twenty-front/src/modules/metadata-store/effect-components/PageLayoutSSEEffect.tsx index fbe8418686..f95296ba57 100644 --- a/packages/twenty-front/src/modules/metadata-store/effect-components/PageLayoutSSEEffect.tsx +++ b/packages/twenty-front/src/modules/metadata-store/effect-components/PageLayoutSSEEffect.tsx @@ -26,6 +26,7 @@ export const PageLayoutSSEEffect = () => { store, 'pageLayouts', eventDetail.operation, + eventDetail.updatedCollectionHash, ); refreshPageLayouts(); diff --git a/packages/twenty-front/src/modules/metadata-store/effect-components/PageLayoutTabSSEEffect.tsx b/packages/twenty-front/src/modules/metadata-store/effect-components/PageLayoutTabSSEEffect.tsx index 4efa02f700..0108be39cc 100644 --- a/packages/twenty-front/src/modules/metadata-store/effect-components/PageLayoutTabSSEEffect.tsx +++ b/packages/twenty-front/src/modules/metadata-store/effect-components/PageLayoutTabSSEEffect.tsx @@ -26,6 +26,7 @@ export const PageLayoutTabSSEEffect = () => { store, 'pageLayoutTabs', eventDetail.operation, + eventDetail.updatedCollectionHash, ); refreshPageLayouts(); diff --git a/packages/twenty-front/src/modules/metadata-store/effect-components/PageLayoutWidgetSSEEffect.tsx b/packages/twenty-front/src/modules/metadata-store/effect-components/PageLayoutWidgetSSEEffect.tsx index 74dcb85624..0bbd0b3fcf 100644 --- a/packages/twenty-front/src/modules/metadata-store/effect-components/PageLayoutWidgetSSEEffect.tsx +++ b/packages/twenty-front/src/modules/metadata-store/effect-components/PageLayoutWidgetSSEEffect.tsx @@ -26,6 +26,7 @@ export const PageLayoutWidgetSSEEffect = () => { store, 'pageLayoutWidgets', eventDetail.operation, + eventDetail.updatedCollectionHash, ); refreshPageLayouts(); diff --git a/packages/twenty-front/src/modules/metadata-store/effect-components/ViewFieldSSEEffect.tsx b/packages/twenty-front/src/modules/metadata-store/effect-components/ViewFieldSSEEffect.tsx index 6b706fd251..22cc77d0e1 100644 --- a/packages/twenty-front/src/modules/metadata-store/effect-components/ViewFieldSSEEffect.tsx +++ b/packages/twenty-front/src/modules/metadata-store/effect-components/ViewFieldSSEEffect.tsx @@ -33,6 +33,7 @@ export const ViewFieldSSEEffect = () => { store, 'viewFields', eventDetail.operation, + eventDetail.updatedCollectionHash, ); const coreViews = store.get(coreViewsState.atom); diff --git a/packages/twenty-front/src/modules/metadata-store/effect-components/ViewFilterGroupSSEEffect.tsx b/packages/twenty-front/src/modules/metadata-store/effect-components/ViewFilterGroupSSEEffect.tsx index cf25b07098..4a10c1cfc7 100644 --- a/packages/twenty-front/src/modules/metadata-store/effect-components/ViewFilterGroupSSEEffect.tsx +++ b/packages/twenty-front/src/modules/metadata-store/effect-components/ViewFilterGroupSSEEffect.tsx @@ -28,6 +28,7 @@ export const ViewFilterGroupSSEEffect = () => { store, 'viewFilterGroups', eventDetail.operation, + eventDetail.updatedCollectionHash, ); const coreViews = store.get(coreViewsState.atom); diff --git a/packages/twenty-front/src/modules/metadata-store/effect-components/ViewFilterSSEEffect.tsx b/packages/twenty-front/src/modules/metadata-store/effect-components/ViewFilterSSEEffect.tsx index 0978da19a0..aaafada345 100644 --- a/packages/twenty-front/src/modules/metadata-store/effect-components/ViewFilterSSEEffect.tsx +++ b/packages/twenty-front/src/modules/metadata-store/effect-components/ViewFilterSSEEffect.tsx @@ -28,6 +28,7 @@ export const ViewFilterSSEEffect = () => { store, 'viewFilters', eventDetail.operation, + eventDetail.updatedCollectionHash, ); const coreViews = store.get(coreViewsState.atom); diff --git a/packages/twenty-front/src/modules/metadata-store/effect-components/ViewSSEEffect.tsx b/packages/twenty-front/src/modules/metadata-store/effect-components/ViewSSEEffect.tsx index 6cdf8c836b..a52c32e2c7 100644 --- a/packages/twenty-front/src/modules/metadata-store/effect-components/ViewSSEEffect.tsx +++ b/packages/twenty-front/src/modules/metadata-store/effect-components/ViewSSEEffect.tsx @@ -27,7 +27,12 @@ export const ViewSSEEffect = () => { useListenToMetadataOperationBrowserEvent({ metadataName: AllMetadataName.view, onMetadataOperationBrowserEvent: async (eventDetail) => { - patchMetadataStoreFromSSEEvent(store, 'views', eventDetail.operation); + patchMetadataStoreFromSSEEvent( + store, + 'views', + eventDetail.operation, + eventDetail.updatedCollectionHash, + ); switch (eventDetail.operation.type) { case 'create': { diff --git a/packages/twenty-front/src/modules/metadata-store/effect-components/ViewSortSSEEffect.tsx b/packages/twenty-front/src/modules/metadata-store/effect-components/ViewSortSSEEffect.tsx index 44b7bc6ce3..e665a476a7 100644 --- a/packages/twenty-front/src/modules/metadata-store/effect-components/ViewSortSSEEffect.tsx +++ b/packages/twenty-front/src/modules/metadata-store/effect-components/ViewSortSSEEffect.tsx @@ -24,7 +24,12 @@ export const ViewSortSSEEffect = () => { useListenToMetadataOperationBrowserEvent({ metadataName: AllMetadataName.viewSort, onMetadataOperationBrowserEvent: (eventDetail) => { - patchMetadataStoreFromSSEEvent(store, 'viewSorts', eventDetail.operation); + patchMetadataStoreFromSSEEvent( + store, + 'viewSorts', + eventDetail.operation, + eventDetail.updatedCollectionHash, + ); const coreViews = store.get(coreViewsState.atom); diff --git a/packages/twenty-front/src/modules/metadata-store/graphql/queries/findMinimalMetadata.ts b/packages/twenty-front/src/modules/metadata-store/graphql/queries/findMinimalMetadata.ts index 117e9f1c7d..44ef62f9e7 100644 --- a/packages/twenty-front/src/modules/metadata-store/graphql/queries/findMinimalMetadata.ts +++ b/packages/twenty-front/src/modules/metadata-store/graphql/queries/findMinimalMetadata.ts @@ -21,7 +21,7 @@ export const FIND_MINIMAL_METADATA = gql` key objectMetadataId } - metadataVersion + collectionHashes } } `; diff --git a/packages/twenty-front/src/modules/metadata-store/hooks/useLoadMinimalMetadata.ts b/packages/twenty-front/src/modules/metadata-store/hooks/useLoadMinimalMetadata.ts index e4945c9543..7187a275ae 100644 --- a/packages/twenty-front/src/modules/metadata-store/hooks/useLoadMinimalMetadata.ts +++ b/packages/twenty-front/src/modules/metadata-store/hooks/useLoadMinimalMetadata.ts @@ -1,9 +1,13 @@ import { FIND_MINIMAL_METADATA } from '@/metadata-store/graphql/queries/findMinimalMetadata'; -import { metadataStoreState } from '@/metadata-store/states/metadataStoreState'; -import { metadataVersionState } from '@/metadata-store/states/metadataVersionState'; +import { metadataCollectionHashesState } from '@/metadata-store/states/metadataCollectionHashesState'; +import { + metadataStoreState, + type MetadataEntityKey, +} from '@/metadata-store/states/metadataStoreState'; import { type FlatObjectMetadataItem } from '@/metadata-store/types/FlatObjectMetadataItem'; import { type FlatView } from '@/metadata-store/types/FlatView'; import { type FindMinimalMetadataQuery } from '@/metadata-store/types/MinimalMetadata'; +import { mapAllMetadataNameToEntityKey } from '@/metadata-store/utils/mapAllMetadataNameToEntityKey'; import { useApolloClient } from '@apollo/client/react'; import { useStore } from 'jotai'; import { useCallback } from 'react'; @@ -23,19 +27,34 @@ export const useLoadMinimalMetadata = () => { return null; } - const { objectMetadataItems, views, metadataVersion } = + const { objectMetadataItems, views, collectionHashes } = result.data.minimalMetadata; - const localMetadataVersion = store.get(metadataVersionState.atom); + const localHashes = store.get(metadataCollectionHashesState.atom); - // Skip hydration if data already loaded with same or newer version - if ( - isDefined(localMetadataVersion) && - localMetadataVersion >= metadataVersion - ) { - return { metadataVersion, isStale: false }; + const serverHashes: Partial> = {}; + + if (isDefined(collectionHashes)) { + for (const [metadataName, hash] of Object.entries( + collectionHashes as Record, + )) { + const entityKey = mapAllMetadataNameToEntityKey(metadataName); + + if (isDefined(entityKey)) { + serverHashes[entityKey] = hash; + } + } } + store.set(metadataCollectionHashesState.atom, serverHashes); + + const staleEntityKeys: MetadataEntityKey[] = Object.entries(serverHashes) + .filter( + ([entityKey, hash]) => + localHashes[entityKey as MetadataEntityKey] !== hash, + ) + .map(([entityKey]) => entityKey as MetadataEntityKey); + const objectsEntry = store.get( metadataStoreState.atomFamily('objectMetadataItems'), ); @@ -58,12 +77,7 @@ export const useLoadMinimalMetadata = () => { }); } - store.set(metadataVersionState.atom, metadataVersion); - - const isStale = - isDefined(localMetadataVersion) && localMetadataVersion < metadataVersion; - - return { metadataVersion, isStale }; + return { staleEntityKeys }; }, [client, store]); return { loadMinimalMetadata }; diff --git a/packages/twenty-front/src/modules/metadata-store/hooks/useMetadataStore.ts b/packages/twenty-front/src/modules/metadata-store/hooks/useMetadataStore.ts index 64f69f5155..dd4322d9af 100644 --- a/packages/twenty-front/src/modules/metadata-store/hooks/useMetadataStore.ts +++ b/packages/twenty-front/src/modules/metadata-store/hooks/useMetadataStore.ts @@ -1,3 +1,4 @@ +import { metadataCollectionHashesState } from '@/metadata-store/states/metadataCollectionHashesState'; import { isAppMetadataReadyState } from '@/metadata-store/states/isAppMetadataReadyState'; import { ALL_METADATA_ENTITY_KEYS, @@ -36,6 +37,7 @@ export const resetMetadataStore = (store: JotaiStore) => { store.set(metadataStoreState.atomFamily(key), EMPTY_ENTRY); } + store.set(metadataCollectionHashesState.atom, {}); store.set(isAppMetadataReadyState.atom, false); }; diff --git a/packages/twenty-front/src/modules/metadata-store/hooks/useStaleMetadataEntities.ts b/packages/twenty-front/src/modules/metadata-store/hooks/useStaleMetadataEntities.ts new file mode 100644 index 0000000000..795ea76167 --- /dev/null +++ b/packages/twenty-front/src/modules/metadata-store/hooks/useStaleMetadataEntities.ts @@ -0,0 +1,27 @@ +import { metadataCollectionHashesState } from '@/metadata-store/states/metadataCollectionHashesState'; +import { + ALL_METADATA_ENTITY_KEYS, + type MetadataEntityKey, +} from '@/metadata-store/states/metadataStoreState'; +import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue'; +import { useMemo } from 'react'; + +// Returns the list of MetadataEntityKey whose local collection hash +// is missing or differs from the last known server collection hash. +export const useStaleMetadataEntities = (): MetadataEntityKey[] => { + const metadataCollectionHashes = useAtomStateValue( + metadataCollectionHashesState, + ); + + return useMemo(() => { + const staleKeys: MetadataEntityKey[] = []; + + for (const entityKey of ALL_METADATA_ENTITY_KEYS) { + if (metadataCollectionHashes[entityKey] === undefined) { + staleKeys.push(entityKey); + } + } + + return staleKeys; + }, [metadataCollectionHashes]); +}; diff --git a/packages/twenty-front/src/modules/metadata-store/states/metadataCollectionHashesState.ts b/packages/twenty-front/src/modules/metadata-store/states/metadataCollectionHashesState.ts new file mode 100644 index 0000000000..a7d40251ca --- /dev/null +++ b/packages/twenty-front/src/modules/metadata-store/states/metadataCollectionHashesState.ts @@ -0,0 +1,11 @@ +import { type MetadataEntityKey } from '@/metadata-store/states/metadataStoreState'; +import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState'; + +export const metadataCollectionHashesState = createAtomState< + Partial> +>({ + key: 'metadataCollectionHashesState', + defaultValue: {}, + useLocalStorage: true, + localStorageOptions: { getOnInit: true }, +}); diff --git a/packages/twenty-front/src/modules/metadata-store/states/metadataVersionState.ts b/packages/twenty-front/src/modules/metadata-store/states/metadataVersionState.ts deleted file mode 100644 index b78166033f..0000000000 --- a/packages/twenty-front/src/modules/metadata-store/states/metadataVersionState.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState'; - -export const metadataVersionState = createAtomState({ - key: 'metadataVersionState', - defaultValue: null, - useLocalStorage: true, - localStorageOptions: { getOnInit: true }, -}); diff --git a/packages/twenty-front/src/modules/metadata-store/utils/mapAllMetadataNameToEntityKey.ts b/packages/twenty-front/src/modules/metadata-store/utils/mapAllMetadataNameToEntityKey.ts new file mode 100644 index 0000000000..98ddfa0341 --- /dev/null +++ b/packages/twenty-front/src/modules/metadata-store/utils/mapAllMetadataNameToEntityKey.ts @@ -0,0 +1,32 @@ +import { type MetadataEntityKey } from '@/metadata-store/states/metadataStoreState'; + +const METADATA_NAME_TO_ENTITY_KEY: Record = { + objectMetadata: 'objectMetadataItems', + fieldMetadata: 'fieldMetadataItems', + index: 'indexMetadataItems', + view: 'views', + viewField: 'viewFields', + viewFieldGroup: 'viewFieldGroups', + viewGroup: 'viewGroups', + viewSort: 'viewSorts', + viewFilter: 'viewFilters', + viewFilterGroup: 'viewFilterGroups', + rowLevelPermissionPredicate: 'rowLevelPermissionPredicates', + rowLevelPermissionPredicateGroup: 'rowLevelPermissionPredicateGroups', + logicFunction: 'logicFunctions', + role: 'roles', + roleTarget: 'roleTargets', + agent: 'agents', + skill: 'skills', + pageLayout: 'pageLayouts', + pageLayoutWidget: 'pageLayoutWidgets', + pageLayoutTab: 'pageLayoutTabs', + commandMenuItem: 'commandMenuItems', + navigationMenuItem: 'navigationMenuItems', + frontComponent: 'frontComponents', + webhook: 'webhooks', +}; + +export const mapAllMetadataNameToEntityKey = ( + metadataName: string, +): MetadataEntityKey | undefined => METADATA_NAME_TO_ENTITY_KEY[metadataName]; diff --git a/packages/twenty-front/src/modules/metadata-store/utils/patchMetadataStoreFromSSEEvent.ts b/packages/twenty-front/src/modules/metadata-store/utils/patchMetadataStoreFromSSEEvent.ts index 23787e396a..c9b863a19f 100644 --- a/packages/twenty-front/src/modules/metadata-store/utils/patchMetadataStoreFromSSEEvent.ts +++ b/packages/twenty-front/src/modules/metadata-store/utils/patchMetadataStoreFromSSEEvent.ts @@ -1,8 +1,10 @@ +import { metadataCollectionHashesState } from '@/metadata-store/states/metadataCollectionHashesState'; import { metadataStoreState, type MetadataEntityKey, } from '@/metadata-store/states/metadataStoreState'; import { type createStore } from 'jotai'; +import { isDefined } from 'twenty-shared/utils'; type JotaiStore = ReturnType; @@ -24,6 +26,7 @@ export const patchMetadataStoreFromSSEEvent = ( store: JotaiStore, entityKey: MetadataEntityKey, operation: SSEEventOperation, + updatedCollectionHash?: string, ) => { const entry = store.get(metadataStoreState.atomFamily(entityKey)); const currentItems = entry.current as Array<{ id: string }>; @@ -57,4 +60,13 @@ export const patchMetadataStoreFromSSEEvent = ( break; } } + + if (isDefined(updatedCollectionHash)) { + const currentHashes = store.get(metadataCollectionHashesState.atom); + + store.set(metadataCollectionHashesState.atom, { + ...currentHashes, + [entityKey]: updatedCollectionHash, + }); + } }; diff --git a/packages/twenty-front/src/modules/sse-db-event/utils/turnSseMetadataEventsToMetadataOperationBrowserEvents.ts b/packages/twenty-front/src/modules/sse-db-event/utils/turnSseMetadataEventsToMetadataOperationBrowserEvents.ts index 7fdea55d36..7d8d864c51 100644 --- a/packages/twenty-front/src/modules/sse-db-event/utils/turnSseMetadataEventsToMetadataOperationBrowserEvents.ts +++ b/packages/twenty-front/src/modules/sse-db-event/utils/turnSseMetadataEventsToMetadataOperationBrowserEvents.ts @@ -17,6 +17,8 @@ export const turnSseMetadataEventsToMetadataOperationBrowserEvents = < }): MetadataOperationBrowserEventDetail[] => { return sseMetadataEvents .map((event): MetadataOperationBrowserEventDetail | null => { + const updatedCollectionHash = event.updatedCollectionHash ?? undefined; + switch (event.type) { case MetadataEventAction.CREATED: { const createdRecord = event.properties.after; @@ -31,6 +33,7 @@ export const turnSseMetadataEventsToMetadataOperationBrowserEvents = < type: 'create', createdRecord, }, + updatedCollectionHash, }; } case MetadataEventAction.UPDATED: { @@ -47,6 +50,7 @@ export const turnSseMetadataEventsToMetadataOperationBrowserEvents = < updatedRecord, updatedFields: event.properties.updatedFields ?? undefined, }, + updatedCollectionHash, }; } case MetadataEventAction.DELETED: { @@ -56,6 +60,7 @@ export const turnSseMetadataEventsToMetadataOperationBrowserEvents = < type: 'delete', deletedRecordId: event.recordId, }, + updatedCollectionHash, }; } default: diff --git a/packages/twenty-sdk/src/clients/generated/metadata/schema.graphql b/packages/twenty-sdk/src/clients/generated/metadata/schema.graphql index 1d55db2050..57e5ac4e4c 100644 --- a/packages/twenty-sdk/src/clients/generated/metadata/schema.graphql +++ b/packages/twenty-sdk/src/clients/generated/metadata/schema.graphql @@ -1245,6 +1245,7 @@ type MetadataEvent { metadataName: String! recordId: String! properties: ObjectRecordEventProperties! + updatedCollectionHash: String } """Metadata Event Action""" @@ -2227,7 +2228,7 @@ type MinimalView { type MinimalMetadata { objectMetadataItems: [MinimalObjectMetadata!]! views: [MinimalView!]! - metadataVersion: Int! + collectionHashes: JSON! } type Webhook { diff --git a/packages/twenty-sdk/src/clients/generated/metadata/schema.ts b/packages/twenty-sdk/src/clients/generated/metadata/schema.ts index 9cbeccff29..59b0074b63 100644 --- a/packages/twenty-sdk/src/clients/generated/metadata/schema.ts +++ b/packages/twenty-sdk/src/clients/generated/metadata/schema.ts @@ -974,6 +974,7 @@ export interface MetadataEvent { metadataName: Scalars['String'] recordId: Scalars['String'] properties: ObjectRecordEventProperties + updatedCollectionHash?: Scalars['String'] __typename: 'MetadataEvent' } @@ -1910,7 +1911,7 @@ export interface MinimalView { export interface MinimalMetadata { objectMetadataItems: MinimalObjectMetadata[] views: MinimalView[] - metadataVersion: Scalars['Int'] + collectionHashes: Scalars['JSON'] __typename: 'MinimalMetadata' } @@ -3902,6 +3903,7 @@ export interface MetadataEventGenqlSelection{ metadataName?: boolean | number recordId?: boolean | number properties?: ObjectRecordEventPropertiesGenqlSelection + updatedCollectionHash?: boolean | number __typename?: boolean | number __scalar?: boolean | number } @@ -4905,7 +4907,7 @@ export interface MinimalViewGenqlSelection{ export interface MinimalMetadataGenqlSelection{ objectMetadataItems?: MinimalObjectMetadataGenqlSelection views?: MinimalViewGenqlSelection - metadataVersion?: boolean | number + collectionHashes?: boolean | number __typename?: boolean | number __scalar?: boolean | number } diff --git a/packages/twenty-sdk/src/clients/generated/metadata/types.ts b/packages/twenty-sdk/src/clients/generated/metadata/types.ts index 4ac4e27d1d..581dc80696 100644 --- a/packages/twenty-sdk/src/clients/generated/metadata/types.ts +++ b/packages/twenty-sdk/src/clients/generated/metadata/types.ts @@ -2589,6 +2589,9 @@ export default { "properties": [ 113 ], + "updatedCollectionHash": [ + 1 + ], "__typename": [ 1 ] @@ -4382,8 +4385,8 @@ export default { "views": [ 237 ], - "metadataVersion": [ - 21 + "collectionHashes": [ + 15 ], "__typename": [ 1 diff --git a/packages/twenty-server/src/engine/metadata-event-emitter/listeners/metadata-events-to-db.listener.ts b/packages/twenty-server/src/engine/metadata-event-emitter/listeners/metadata-events-to-db.listener.ts index 2f2916f8d4..e11e09fe1b 100644 --- a/packages/twenty-server/src/engine/metadata-event-emitter/listeners/metadata-events-to-db.listener.ts +++ b/packages/twenty-server/src/engine/metadata-event-emitter/listeners/metadata-events-to-db.listener.ts @@ -7,7 +7,10 @@ import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decora import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants'; import { MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service'; import { type MetadataEventBatch } from 'src/engine/metadata-event-emitter/types/metadata-event-batch.type'; +import { getMetadataFlatEntityMapsKey } from 'src/engine/metadata-modules/flat-entity/utils/get-metadata-flat-entity-maps-key.util'; import { CallWebhookJobsForMetadataJob } from 'src/engine/metadata-modules/webhook/jobs/call-webhook-jobs-for-metadata.job'; +import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service'; +import { type WorkspaceCacheKeyName } from 'src/engine/workspace-cache/types/workspace-cache-key.type'; import { WorkspaceEventEmitterService } from 'src/engine/workspace-event-emitter/workspace-event-emitter.service'; import { type AllMetadataEventType } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/types/metadata-event'; @@ -17,6 +20,7 @@ export class MetadataEventsToDbListener { @InjectMessageQueue(MessageQueue.webhookQueue) private readonly webhookQueueService: MessageQueueService, private readonly workspaceEventEmitterService: WorkspaceEventEmitterService, + private readonly workspaceCacheService: WorkspaceCacheService, ) {} @OnEvent('metadata.*.created') @@ -53,7 +57,21 @@ export class MetadataEventsToDbListener { }); if (metadataEventBatch.events.length > 0) { - await this.workspaceEventEmitterService.publish(metadataEventBatch); + const cacheKeyName = getMetadataFlatEntityMapsKey( + metadataEventBatch.metadataName, + ) as WorkspaceCacheKeyName; + + const cacheHashes = await this.workspaceCacheService.getCacheHashes( + metadataEventBatch.workspaceId, + [cacheKeyName], + ); + + const updatedCollectionHash = cacheHashes[cacheKeyName]; + + await this.workspaceEventEmitterService.publish({ + ...metadataEventBatch, + updatedCollectionHash, + }); } } } diff --git a/packages/twenty-server/src/engine/metadata-event-emitter/metadata-event-emitter.module.ts b/packages/twenty-server/src/engine/metadata-event-emitter/metadata-event-emitter.module.ts index 1ae18b09dd..3e89c281a6 100644 --- a/packages/twenty-server/src/engine/metadata-event-emitter/metadata-event-emitter.module.ts +++ b/packages/twenty-server/src/engine/metadata-event-emitter/metadata-event-emitter.module.ts @@ -3,10 +3,11 @@ import { Global, Module } from '@nestjs/common'; import { MetadataEventsToDbListener } from 'src/engine/metadata-event-emitter/listeners/metadata-events-to-db.listener'; import { MetadataEventEmitter } from 'src/engine/metadata-event-emitter/metadata-event-emitter'; import { SubscriptionsModule } from 'src/engine/subscriptions/subscriptions.module'; +import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module'; @Global() @Module({ - imports: [SubscriptionsModule], + imports: [SubscriptionsModule, WorkspaceCacheModule], providers: [MetadataEventEmitter, MetadataEventsToDbListener], exports: [MetadataEventEmitter], }) diff --git a/packages/twenty-server/src/engine/metadata-event-emitter/types/metadata-event-batch.type.ts b/packages/twenty-server/src/engine/metadata-event-emitter/types/metadata-event-batch.type.ts index 3d7fdfc779..71d6dc1037 100644 --- a/packages/twenty-server/src/engine/metadata-event-emitter/types/metadata-event-batch.type.ts +++ b/packages/twenty-server/src/engine/metadata-event-emitter/types/metadata-event-batch.type.ts @@ -16,4 +16,5 @@ export type MetadataEventBatch< events: MetadataEvent[]; userId?: string; apiKeyId?: string; + updatedCollectionHash?: string; }; diff --git a/packages/twenty-server/src/engine/metadata-modules/minimal-metadata/dtos/minimal-metadata.dto.ts b/packages/twenty-server/src/engine/metadata-modules/minimal-metadata/dtos/minimal-metadata.dto.ts index 5626488899..0eec46bb05 100644 --- a/packages/twenty-server/src/engine/metadata-modules/minimal-metadata/dtos/minimal-metadata.dto.ts +++ b/packages/twenty-server/src/engine/metadata-modules/minimal-metadata/dtos/minimal-metadata.dto.ts @@ -1,4 +1,5 @@ -import { Field, Int, ObjectType } from '@nestjs/graphql'; +import { Field, ObjectType } from '@nestjs/graphql'; +import { GraphQLJSON } from 'graphql-type-json'; import { MinimalObjectMetadataDTO } from 'src/engine/metadata-modules/minimal-metadata/dtos/minimal-object-metadata.dto'; import { MinimalViewDTO } from 'src/engine/metadata-modules/minimal-metadata/dtos/minimal-view.dto'; @@ -11,6 +12,6 @@ export class MinimalMetadataDTO { @Field(() => [MinimalViewDTO]) views: MinimalViewDTO[]; - @Field(() => Int) - metadataVersion: number; + @Field(() => GraphQLJSON) + collectionHashes: Record; } diff --git a/packages/twenty-server/src/engine/metadata-modules/minimal-metadata/minimal-metadata.module.ts b/packages/twenty-server/src/engine/metadata-modules/minimal-metadata/minimal-metadata.module.ts index b3b5c630f3..c8eebfc509 100644 --- a/packages/twenty-server/src/engine/metadata-modules/minimal-metadata/minimal-metadata.module.ts +++ b/packages/twenty-server/src/engine/metadata-modules/minimal-metadata/minimal-metadata.module.ts @@ -1,16 +1,12 @@ import { Module } from '@nestjs/common'; -import { TypeOrmModule } from '@nestjs/typeorm'; -import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module'; import { MinimalMetadataResolver } from 'src/engine/metadata-modules/minimal-metadata/minimal-metadata.resolver'; import { MinimalMetadataService } from 'src/engine/metadata-modules/minimal-metadata/minimal-metadata.service'; +import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module'; @Module({ - imports: [ - TypeOrmModule.forFeature([WorkspaceEntity]), - WorkspaceManyOrAllFlatEntityMapsCacheModule, - ], + imports: [WorkspaceManyOrAllFlatEntityMapsCacheModule, WorkspaceCacheModule], providers: [MinimalMetadataResolver, MinimalMetadataService], exports: [MinimalMetadataService], }) diff --git a/packages/twenty-server/src/engine/metadata-modules/minimal-metadata/minimal-metadata.service.ts b/packages/twenty-server/src/engine/metadata-modules/minimal-metadata/minimal-metadata.service.ts index 9f2f1f980d..48623f5700 100644 --- a/packages/twenty-server/src/engine/metadata-modules/minimal-metadata/minimal-metadata.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/minimal-metadata/minimal-metadata.service.ts @@ -1,40 +1,66 @@ import { Injectable } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; +import { + ALL_METADATA_NAME, + type AllMetadataName, +} from 'twenty-shared/metadata'; import { ViewVisibility } from 'twenty-shared/types'; -import { isDefined } from 'twenty-shared/utils'; -import { Repository } from 'typeorm'; +import { isDefined, uncapitalize } from 'twenty-shared/utils'; -import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; +import { ALL_FLAT_ENTITY_MAPS_PROPERTIES } from 'src/engine/metadata-modules/flat-entity/constant/all-flat-entity-maps-properties.constant'; import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service'; import { MinimalMetadataDTO } from 'src/engine/metadata-modules/minimal-metadata/dtos/minimal-metadata.dto'; import { MinimalObjectMetadataDTO } from 'src/engine/metadata-modules/minimal-metadata/dtos/minimal-object-metadata.dto'; import { MinimalViewDTO } from 'src/engine/metadata-modules/minimal-metadata/dtos/minimal-view.dto'; +import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service'; +import { type WorkspaceCacheKeyName } from 'src/engine/workspace-cache/types/workspace-cache-key.type'; + +// Inverse of getMetadataFlatEntityMapsKey: "flatObjectMetadataMaps" -> "objectMetadata" +const flatMapsKeyToMetadataName = ( + flatMapsKey: string, +): AllMetadataName | undefined => { + const withoutPrefix = flatMapsKey.replace(/^flat/, ''); + const withoutSuffix = withoutPrefix.replace(/Maps$/, ''); + const metadataName = uncapitalize(withoutSuffix); + + return metadataName in ALL_METADATA_NAME + ? (metadataName as AllMetadataName) + : undefined; +}; @Injectable() export class MinimalMetadataService { constructor( - @InjectRepository(WorkspaceEntity) - private readonly workspaceRepository: Repository, private readonly flatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService, + private readonly workspaceCacheService: WorkspaceCacheService, ) {} async getMinimalMetadata( workspaceId: string, userWorkspaceId?: string, ): Promise { - const [workspace, { flatObjectMetadataMaps, flatViewMaps }] = + const [{ flatObjectMetadataMaps, flatViewMaps }, cacheHashes] = await Promise.all([ - this.workspaceRepository.findOneOrFail({ - where: { id: workspaceId }, - select: ['metadataVersion'], - }), this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps({ workspaceId, flatMapsKeys: ['flatObjectMetadataMaps', 'flatViewMaps'], }), + this.workspaceCacheService.getCacheHashes( + workspaceId, + ALL_FLAT_ENTITY_MAPS_PROPERTIES as WorkspaceCacheKeyName[], + ), ]); + const collectionHashes: Record = {}; + + for (const [cacheKey, hash] of Object.entries(cacheHashes)) { + const metadataName = flatMapsKeyToMetadataName(cacheKey); + + if (isDefined(metadataName) && isDefined(hash)) { + collectionHashes[metadataName] = hash; + } + } + const objectMetadataItems: MinimalObjectMetadataDTO[] = Object.values( flatObjectMetadataMaps.byUniversalIdentifier, ) @@ -76,7 +102,7 @@ export class MinimalMetadataService { return { objectMetadataItems, views, - metadataVersion: workspace.metadataVersion, + collectionHashes, }; } } diff --git a/packages/twenty-server/src/engine/subscriptions/dtos/metadata-event.dto.ts b/packages/twenty-server/src/engine/subscriptions/dtos/metadata-event.dto.ts index 6b7bcdd4fe..3ed17b7bcc 100644 --- a/packages/twenty-server/src/engine/subscriptions/dtos/metadata-event.dto.ts +++ b/packages/twenty-server/src/engine/subscriptions/dtos/metadata-event.dto.ts @@ -16,4 +16,7 @@ export class MetadataEventDTO { @Field(() => ObjectRecordEventPropertiesDTO) properties: ObjectRecordEventPropertiesDTO; + + @Field(() => String, { nullable: true }) + updatedCollectionHash?: string; } diff --git a/packages/twenty-server/src/engine/workspace-cache/services/workspace-cache.service.ts b/packages/twenty-server/src/engine/workspace-cache/services/workspace-cache.service.ts index c5703ce674..cfa88d2090 100644 --- a/packages/twenty-server/src/engine/workspace-cache/services/workspace-cache.service.ts +++ b/packages/twenty-server/src/engine/workspace-cache/services/workspace-cache.service.ts @@ -170,6 +170,31 @@ export class WorkspaceCacheService implements OnModuleInit { await this.recomputeDataFromProvider(workspaceId, cacheKeyNames); } + public async getCacheHashes( + workspaceId: string, + cacheKeyNames: WorkspaceCacheKeyName[], + ): Promise>> { + if (cacheKeyNames.length === 0) { + return {}; + } + + const hashKeys = cacheKeyNames.map( + (keyName) => `${this.buildCacheKey(workspaceId, keyName)}:hash`, + ); + + const hashes = await this.cacheStorage.mget(hashKeys); + + const result: Partial> = {}; + + for (const [index, keyName] of cacheKeyNames.entries()) { + if (isDefined(hashes[index])) { + result[keyName] = hashes[index]; + } + } + + return result; + } + public async flush( workspaceId: string, cacheKeyNames: WorkspaceCacheKeyName[], diff --git a/packages/twenty-server/src/engine/workspace-event-emitter/workspace-event-emitter.service.ts b/packages/twenty-server/src/engine/workspace-event-emitter/workspace-event-emitter.service.ts index 7ed6fd78df..11e10a3be7 100644 --- a/packages/twenty-server/src/engine/workspace-event-emitter/workspace-event-emitter.service.ts +++ b/packages/twenty-server/src/engine/workspace-event-emitter/workspace-event-emitter.service.ts @@ -188,7 +188,7 @@ export class WorkspaceEventEmitterService { ): Promise { const metadataEventsWithQueryIds: { queryIds: string[]; - metadataEvent: MetadataEvent; + metadataEvent: MetadataEvent & { updatedCollectionHash?: string }; }[] = []; for (const metadataEvent of metadataEventBatch.events) { @@ -203,7 +203,10 @@ export class WorkspaceEventEmitterService { metadataEventsWithQueryIds.push({ queryIds: matchedQueryIds, - metadataEvent, + metadataEvent: { + ...metadataEvent, + updatedCollectionHash: metadataEventBatch.updatedCollectionHash, + }, }); }