feat: migrate objectMetadata reads to granular metadata store (#18643)

## Summary

Consolidates `objectMetadataItems` onto the metadata store as the
**single source of truth**, replacing the previous dual-store approach
(separate `objectMetadataItemsState` atom + untyped
`metadataStoreState`).

### Architecture: three-layer design

```
┌─────────────────────────────────────────────────────────┐
│ Store Layer (granular, typed)                           │
│  objectMetadataItems → FlatObjectMetadataItem[]         │
│  fieldMetadataItems  → FlatFieldMetadataItem[]          │
│  indexMetadataItems  → FlatIndexMetadataItem[]           │
└────────────────┬────────────────────────────────────────┘
                 │ .current (never draft)
┌────────────────▼────────────────────────────────────────┐
│ Selectors (typed read-only)                             │
│  objectMetadataItemsSelector                            │
│  fieldMetadataItemsSelector                             │
│  indexMetadataItemsSelector                             │
│  metadataStoreStatusFamilySelector                      │
│  isSystemObjectByNameSingularFamilySelector (narrow)    │
│  activeObjectNameSingularsSelector (narrow)             │
└────────────────┬────────────────────────────────────────┘
                 │ joins objects + fields + indexes + permissions
┌────────────────▼────────────────────────────────────────┐
│ Joining Selector                                        │
│  objectMetadataItemsWithFieldsSelector                  │
│  → produces full ObjectMetadataItem[] with              │
│    readableFields / updatableFields from permissions    │
│  → 12 existing selectors repointed here                 │
└─────────────────────────────────────────────────────────┘
```

### Key changes

- **Granular flat types** (`FlatObjectMetadataItem`,
`FlatFieldMetadataItem`, `FlatIndexMetadataItem`) — objects stored
without embedded fields/indexes, matching backend "Flat" naming
convention
- **Typed write API** — `updateDraft` is now generic via
`MetadataEntityTypeMap`, giving compile-time safety on what data shape
goes to each key
- **Write path refactored** — fetch → split into flat entities via
`splitObjectMetadataItemWithRelated` → write to metadata store directly.
No more dual-write through `objectMetadataItemsState`. Permissions
enrichment moved from write path into the joining selector.
- **SSE effects write directly** — `ObjectMetadataItemSSEEffect` and
`FieldMetadataSSEEffect` now patch the store from the SSE event payload
(create/update/delete) instead of triggering a full re-fetch
- **`objectMetadataItemsState` bridge** — converted from writable
`createAtomState` to read-only `createAtomSelector` that delegates to
the joining selector. All 100+ existing consumers continue to work
without code changes.
- **All selectors use Twenty state API** — `createAtomSelector` /
`createAtomFamilySelector` throughout, no raw `atom()`
- **Narrow selectors** for hot paths —
`isSystemObjectByNameSingularFamilySelector` and
`activeObjectNameSingularsSelector` read from flat objects only,
avoiding re-renders when fields/indexes/permissions change. Placed in
`object-metadata/states/` as higher-level business selectors.
- **Test helper** — `setTestObjectMetadataItemsInMetadataStore` for
tests that need to set up composite object metadata through the store
(clearly named as a testing utility)

### Naming conventions

- `ObjectMetadataItemWithRelated` — type for objects with embedded
fields/indexes (input to split utility)
- `FlatObjectMetadataItem` / `FlatFieldMetadataItem` /
`FlatIndexMetadataItem` — granular store types
- Selector names don't expose "Current" — that's an internal detail of
the metadata store API

### Future work

- Optimistic update API (`updateCurrentOptimistically` with rollback)
- Migrate remaining entities (views, pageLayouts, etc.) to the same
pattern
- Gradually remove `objectMetadataItemsState` bridge once all direct
imports are replaced

## Test plan

- [x] `npx nx typecheck twenty-front` passes
- [x] `npx nx lint:diff-with-main twenty-front` passes
- [ ] Verify app loads correctly with metadata from the store
- [ ] Verify SSE updates (object/field changes) propagate correctly
- [ ] Run existing test suites to confirm no regressions
This commit is contained in:
Charles Bochet
2026-03-14 12:54:19 +01:00
committed by GitHub
parent 48172d60fd
commit 40ff109179
64 changed files with 657 additions and 315 deletions
@@ -1,7 +1,6 @@
import { useListenToMetadataOperationBrowserEvent } from '@/browser-event/hooks/useListenToMetadataOperationBrowserEvent';
import { useMetadataStore } from '@/metadata-store/hooks/useMetadataStore';
import { useRefreshObjectMetadataItems } from '@/object-metadata/hooks/useRefreshObjectMetadataItems';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { metadataStoreState } from '@/metadata-store/states/metadataStoreState';
import { type FlatFieldMetadataItem } from '@/metadata-store/types/FlatFieldMetadataItem';
import { useListenToEventsForQuery } from '@/sse-db-event/hooks/useListenToEventsForQuery';
import { useStore } from 'jotai';
import { AllMetadataName } from '~/generated-metadata/graphql';
@@ -11,9 +10,6 @@ export const FieldMetadataSSEEffect = () => {
const store = useStore();
const { refreshObjectMetadataItems } = useRefreshObjectMetadataItems();
const { updateDraft, applyChanges } = useMetadataStore();
useListenToEventsForQuery({
queryId,
operationSignature: {
@@ -24,12 +20,52 @@ export const FieldMetadataSSEEffect = () => {
useListenToMetadataOperationBrowserEvent({
metadataName: AllMetadataName.fieldMetadata,
onMetadataOperationBrowserEvent: async () => {
await refreshObjectMetadataItems();
onMetadataOperationBrowserEvent: (eventDetail) => {
const entry = store.get(
metadataStoreState.atomFamily('fieldMetadataItems'),
);
const currentFields = entry.current as FlatFieldMetadataItem[];
const loadedObjects = store.get(objectMetadataItemsState.atom);
updateDraft('objectMetadataItems', loadedObjects);
applyChanges();
switch (eventDetail.operation.type) {
case 'create': {
const createdField = eventDetail.operation
.createdRecord as unknown as FlatFieldMetadataItem;
store.set(metadataStoreState.atomFamily('fieldMetadataItems'), {
...entry,
current: [...currentFields, createdField],
});
break;
}
case 'update': {
const updatedField = eventDetail.operation
.updatedRecord as unknown as FlatFieldMetadataItem;
store.set(metadataStoreState.atomFamily('fieldMetadataItems'), {
...entry,
current: currentFields.map((field) =>
field.id === updatedField.id
? { ...field, ...updatedField }
: field,
),
});
break;
}
case 'delete': {
const deletedFieldId = eventDetail.operation
.deletedRecordId as string;
store.set(metadataStoreState.atomFamily('fieldMetadataItems'), {
...entry,
current: currentFields.filter(
(field) => field.id !== deletedFieldId,
),
});
break;
}
default:
return;
}
},
});
@@ -1,7 +1,6 @@
import { useListenToMetadataOperationBrowserEvent } from '@/browser-event/hooks/useListenToMetadataOperationBrowserEvent';
import { useMetadataStore } from '@/metadata-store/hooks/useMetadataStore';
import { useRefreshObjectMetadataItems } from '@/object-metadata/hooks/useRefreshObjectMetadataItems';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { metadataStoreState } from '@/metadata-store/states/metadataStoreState';
import { type FlatObjectMetadataItem } from '@/metadata-store/types/FlatObjectMetadataItem';
import { navigationMenuItemsState } from '@/navigation-menu-item/states/navigationMenuItemsState';
import { useListenToEventsForQuery } from '@/sse-db-event/hooks/useListenToEventsForQuery';
import { useStore } from 'jotai';
@@ -18,9 +17,6 @@ export const ObjectMetadataItemSSEEffect = () => {
const store = useStore();
const client = useApolloClient();
const { refreshObjectMetadataItems } = useRefreshObjectMetadataItems();
const { updateDraft, applyChanges } = useMetadataStore();
useListenToEventsForQuery({
queryId,
operationSignature: {
@@ -31,12 +27,52 @@ export const ObjectMetadataItemSSEEffect = () => {
useListenToMetadataOperationBrowserEvent({
metadataName: AllMetadataName.objectMetadata,
onMetadataOperationBrowserEvent: async () => {
await refreshObjectMetadataItems();
onMetadataOperationBrowserEvent: async (eventDetail) => {
const entry = store.get(
metadataStoreState.atomFamily('objectMetadataItems'),
);
const currentObjects = entry.current as FlatObjectMetadataItem[];
const loadedObjects = store.get(objectMetadataItemsState.atom);
updateDraft('objectMetadataItems', loadedObjects);
applyChanges();
switch (eventDetail.operation.type) {
case 'create': {
const createdObject = eventDetail.operation
.createdRecord as unknown as FlatObjectMetadataItem;
store.set(metadataStoreState.atomFamily('objectMetadataItems'), {
...entry,
current: [...currentObjects, createdObject],
});
break;
}
case 'update': {
const updatedObject = eventDetail.operation
.updatedRecord as unknown as FlatObjectMetadataItem;
store.set(metadataStoreState.atomFamily('objectMetadataItems'), {
...entry,
current: currentObjects.map((object) =>
object.id === updatedObject.id
? { ...object, ...updatedObject }
: object,
),
});
break;
}
case 'delete': {
const deletedObjectId = eventDetail.operation
.deletedRecordId as string;
store.set(metadataStoreState.atomFamily('objectMetadataItems'), {
...entry,
current: currentObjects.filter(
(object) => object.id !== deletedObjectId,
),
});
break;
}
default:
return;
}
const navigationMenuItemsResult = await client.query({
query: FindManyNavigationMenuItemsDocument,
@@ -1,23 +1,18 @@
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { isCurrentUserLoadedState } from '@/auth/states/isCurrentUserLoadedState';
import { useMetadataStore } from '@/metadata-store/hooks/useMetadataStore';
import { useLoadMockedObjectMetadataItems } from '@/object-metadata/hooks/useLoadMockedObjectMetadataItems';
import { useRefreshObjectMetadataItems } from '@/object-metadata/hooks/useRefreshObjectMetadataItems';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useStore } from 'jotai';
import { useEffect, useState } from 'react';
import { isWorkspaceActiveOrSuspended } from 'twenty-shared/workspace';
export const ObjectMetadataProviderInitialEffect = () => {
const isCurrentUserLoaded = useAtomStateValue(isCurrentUserLoadedState);
const currentWorkspace = useAtomStateValue(currentWorkspaceState);
const store = useStore();
const [isInitialized, setIsInitialized] = useState(false);
const { refreshObjectMetadataItems } = useRefreshObjectMetadataItems();
const { loadMockedObjectMetadataItems } = useLoadMockedObjectMetadataItems();
const { updateDraft, applyChanges } = useMetadataStore();
useEffect(() => {
if (isInitialized) {
@@ -36,9 +31,6 @@ export const ObjectMetadataProviderInitialEffect = () => {
await loadMockedObjectMetadataItems();
}
const loadedItems = store.get(objectMetadataItemsState.atom);
updateDraft('objectMetadataItems', loadedItems);
applyChanges();
setIsInitialized(true);
};
@@ -49,9 +41,6 @@ export const ObjectMetadataProviderInitialEffect = () => {
currentWorkspace,
refreshObjectMetadataItems,
loadMockedObjectMetadataItems,
store,
updateDraft,
applyChanges,
]);
return null;
@@ -1,5 +1,6 @@
import { useMetadataStore } from '@/metadata-store/hooks/useMetadataStore';
import { useSetIndexViews } from '@/metadata-store/hooks/useSetIndexViews';
import { type View } from '@/views/types/View';
import { useCallback } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { useApolloClient } from '@apollo/client/react';
@@ -24,7 +25,8 @@ export const useFetchAndLoadIndexViews = () => {
if (isDefined(result.data?.getCoreViews)) {
setIndexViews(result.data.getCoreViews);
updateDraft('views', result.data.getCoreViews);
// TODO: align generated ViewType with app ViewType to remove this cast
updateDraft('views', result.data.getCoreViews as unknown as View[]);
applyChanges();
}
}, [client, setIndexViews, updateDraft, applyChanges]);
@@ -5,6 +5,7 @@ import {
type MetadataEntityKey,
type MetadataStoreItem,
} from '@/metadata-store/states/metadataStoreState';
import { type MetadataEntityTypeMap } from '@/metadata-store/types/MetadataEntityTypeMap';
import { useStore, type createStore } from 'jotai';
import { useCallback } from 'react';
import { isDeeplyEqual } from '~/utils/isDeeplyEqual';
@@ -55,7 +56,7 @@ export const useMetadataStore = () => {
const store = useStore();
const updateDraft = useCallback(
(key: MetadataEntityKey, data: object[]) => {
<K extends MetadataEntityKey>(key: K, data: MetadataEntityTypeMap[K][]) => {
const currentEntry = store.get(metadataStoreState.atomFamily(key));
if (
@@ -1,7 +1,7 @@
import { useMetadataStore } from '@/metadata-store/hooks/useMetadataStore';
import { useLoadMockedObjectMetadataItems } from '@/object-metadata/hooks/useLoadMockedObjectMetadataItems';
import { useRefreshObjectMetadataItems } from '@/object-metadata/hooks/useRefreshObjectMetadataItems';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { type View } from '@/views/types/View';
import { coreViewsState } from '@/views/states/coreViewState';
import { useStore } from 'jotai';
import { useCallback } from 'react';
@@ -16,11 +16,9 @@ export const useReloadWorkspaceMetadata = () => {
resetMetadataStore();
await refreshObjectMetadataItems();
const loadedObjects = store.get(objectMetadataItemsState.atom);
updateDraft('objectMetadataItems', loadedObjects);
applyChanges();
const loadedViews = store.get(coreViewsState.atom);
// TODO: align generated ViewType with app ViewType to remove this cast
const loadedViews = store.get(coreViewsState.atom) as unknown as View[];
updateDraft('views', loadedViews);
applyChanges();
}, [
@@ -35,16 +33,7 @@ export const useReloadWorkspaceMetadata = () => {
resetMetadataStore();
await loadMockedObjectMetadataItems();
const loadedObjects = store.get(objectMetadataItemsState.atom);
updateDraft('objectMetadataItems', loadedObjects);
applyChanges();
}, [
resetMetadataStore,
loadMockedObjectMetadataItems,
store,
updateDraft,
applyChanges,
]);
}, [resetMetadataStore, loadMockedObjectMetadataItems]);
return { reloadWorkspaceMetadata, resetToMockedMetadata };
};
@@ -0,0 +1,14 @@
import { metadataStoreState } from '@/metadata-store/states/metadataStoreState';
import { type FlatFieldMetadataItem } from '@/metadata-store/types/FlatFieldMetadataItem';
import { createAtomSelector } from '@/ui/utilities/state/jotai/utils/createAtomSelector';
export const fieldMetadataItemsSelector = createAtomSelector<
FlatFieldMetadataItem[]
>({
key: 'fieldMetadataItemsSelector',
get: ({ get }) => {
const storeItem = get(metadataStoreState, 'fieldMetadataItems');
return storeItem.current as FlatFieldMetadataItem[];
},
});
@@ -0,0 +1,14 @@
import { metadataStoreState } from '@/metadata-store/states/metadataStoreState';
import { type FlatIndexMetadataItem } from '@/metadata-store/types/FlatIndexMetadataItem';
import { createAtomSelector } from '@/ui/utilities/state/jotai/utils/createAtomSelector';
export const indexMetadataItemsSelector = createAtomSelector<
FlatIndexMetadataItem[]
>({
key: 'indexMetadataItemsSelector',
get: ({ get }) => {
const storeItem = get(metadataStoreState, 'indexMetadataItems');
return storeItem.current as FlatIndexMetadataItem[];
},
});
@@ -8,6 +8,7 @@ export type MetadataEntityStoreStatus =
export const ALL_METADATA_ENTITY_KEYS = [
'objectMetadataItems',
'fieldMetadataItems',
'indexMetadataItems',
'views',
'viewFields',
'viewFilters',
@@ -0,0 +1,20 @@
import {
metadataStoreState,
type MetadataEntityKey,
type MetadataEntityStoreStatus,
} from '@/metadata-store/states/metadataStoreState';
import { createAtomFamilySelector } from '@/ui/utilities/state/jotai/utils/createAtomFamilySelector';
export const metadataStoreStatusFamilySelector = createAtomFamilySelector<
MetadataEntityStoreStatus,
MetadataEntityKey
>({
key: 'metadataStoreStatusFamilySelector',
get:
(entityKey: MetadataEntityKey) =>
({ get }) => {
const storeItem = get(metadataStoreState, entityKey);
return storeItem.status;
},
});
@@ -0,0 +1,14 @@
import { metadataStoreState } from '@/metadata-store/states/metadataStoreState';
import { type FlatObjectMetadataItem } from '@/metadata-store/types/FlatObjectMetadataItem';
import { createAtomSelector } from '@/ui/utilities/state/jotai/utils/createAtomSelector';
export const objectMetadataItemsSelector = createAtomSelector<
FlatObjectMetadataItem[]
>({
key: 'objectMetadataItemsSelector',
get: ({ get }) => {
const storeItem = get(metadataStoreState, 'objectMetadataItems');
return storeItem.current as FlatObjectMetadataItem[];
},
});
@@ -0,0 +1,5 @@
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
export type FlatFieldMetadataItem = FieldMetadataItem & {
objectMetadataId: string;
};
@@ -0,0 +1,5 @@
import { type IndexMetadataItem } from '@/object-metadata/types/IndexMetadataItem';
export type FlatIndexMetadataItem = IndexMetadataItem & {
objectMetadataId: string;
};
@@ -0,0 +1,6 @@
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
export type FlatObjectMetadataItem = Omit<
ObjectMetadataItem,
'fields' | 'readableFields' | 'updatableFields' | 'indexMetadatas'
>;
@@ -0,0 +1,23 @@
import { type FlatFieldMetadataItem } from '@/metadata-store/types/FlatFieldMetadataItem';
import { type FlatIndexMetadataItem } from '@/metadata-store/types/FlatIndexMetadataItem';
import { type FlatObjectMetadataItem } from '@/metadata-store/types/FlatObjectMetadataItem';
import { type PageLayout } from '@/page-layout/types/PageLayout';
import { type LogicFunction } from '@/settings/logic-functions/states/logicFunctionsState';
import { type View } from '@/views/types/View';
import { type ViewField } from '@/views/types/ViewField';
import { type ViewFilter } from '@/views/types/ViewFilter';
import { type ViewSort } from '@/views/types/ViewSort';
import { type NavigationMenuItem } from '~/generated-metadata/graphql';
export type MetadataEntityTypeMap = {
objectMetadataItems: FlatObjectMetadataItem;
fieldMetadataItems: FlatFieldMetadataItem;
indexMetadataItems: FlatIndexMetadataItem;
views: View;
viewFields: ViewField;
viewFilters: ViewFilter;
viewSorts: ViewSort;
pageLayouts: PageLayout;
logicFunctions: LogicFunction;
navigationMenuItems: NavigationMenuItem;
};
@@ -0,0 +1,49 @@
import { type FlatFieldMetadataItem } from '@/metadata-store/types/FlatFieldMetadataItem';
import { type FlatIndexMetadataItem } from '@/metadata-store/types/FlatIndexMetadataItem';
import { type FlatObjectMetadataItem } from '@/metadata-store/types/FlatObjectMetadataItem';
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
export type ObjectMetadataItemWithRelated = Omit<
ObjectMetadataItem,
'readableFields' | 'updatableFields'
>;
type SplitResult = {
flatObjects: FlatObjectMetadataItem[];
flatFields: FlatFieldMetadataItem[];
flatIndexes: FlatIndexMetadataItem[];
};
export const splitObjectMetadataItemWithRelated = (
objectMetadataItemsWithRelated: ObjectMetadataItemWithRelated[],
): SplitResult => {
const flatObjects: FlatObjectMetadataItem[] = [];
const flatFields: FlatFieldMetadataItem[] = [];
const flatIndexes: FlatIndexMetadataItem[] = [];
for (const objectMetadataItemWithRelated of objectMetadataItemsWithRelated) {
const {
fields = [],
indexMetadatas = [],
...objectProperties
} = objectMetadataItemWithRelated;
flatObjects.push(objectProperties);
for (const field of fields) {
flatFields.push({
...field,
objectMetadataId: objectMetadataItemWithRelated.id,
});
}
for (const index of indexMetadatas) {
flatIndexes.push({
...index,
objectMetadataId: objectMetadataItemWithRelated.id,
});
}
}
return { flatObjects, flatFields, flatIndexes };
};