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,13 +1,16 @@
import { renderHook } from '@testing-library/react';
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
import { setTestObjectMetadataItemsInMetadataStore } from '~/testing/utils/setTestObjectMetadataItemsInMetadataStore';
import { jotaiStore } from '@/ui/utilities/state/jotai/jotaiStore';
import { getJestMetadataAndApolloMocksWrapper } from '~/testing/jest/getJestMetadataAndApolloMocksWrapper';
import { generatedMockObjectMetadataItems } from '~/testing/utils/generatedMockObjectMetadataItems';
jotaiStore.set(objectMetadataItemsState.atom, generatedMockObjectMetadataItems);
setTestObjectMetadataItemsInMetadataStore(
jotaiStore,
generatedMockObjectMetadataItems,
);
const Wrapper = getJestMetadataAndApolloMocksWrapper({
apolloMocks: [],
@@ -1,8 +1,8 @@
import { gql } from '@apollo/client';
import { renderHook, waitFor } from '@testing-library/react';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { type RecordGqlFields } from '@/object-record/graphql/record-gql-fields/types/RecordGqlFields';
import { setTestObjectMetadataItemsInMetadataStore } from '~/testing/utils/setTestObjectMetadataItemsInMetadataStore';
import { type RecordGqlOperationSignature } from 'twenty-shared/types';
import { useCombinedFindManyRecords } from '@/object-record/multiple-objects/hooks/useCombinedFindManyRecords';
import { useGenerateCombinedFindManyRecordsQuery } from '@/object-record/multiple-objects/hooks/useGenerateCombinedFindManyRecordsQuery';
@@ -123,8 +123,8 @@ const renderUseCombinedFindManyRecordsHook = async ({
},
];
jotaiStore.set(
objectMetadataItemsState.atom,
setTestObjectMetadataItemsInMetadataStore(
jotaiStore,
generatedMockObjectMetadataItems,
);
@@ -1,7 +1,7 @@
import { type Meta, type StoryObj } from '@storybook/react-vite';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { RecordComponentInstanceContextsWrapper } from '@/object-record/components/RecordComponentInstanceContextsWrapper';
import { setTestObjectMetadataItemsInMetadataStore } from '~/testing/utils/setTestObjectMetadataItemsInMetadataStore';
import { ObjectOptionsDropdownContent } from '@/object-record/object-options-dropdown/components/ObjectOptionsDropdownContent';
import { OBJECT_OPTIONS_DROPDOWN_ID } from '@/object-record/object-options-dropdown/constants/ObjectOptionsDropdownId';
import { ObjectOptionsDropdownContext } from '@/object-record/object-options-dropdown/states/contexts/ObjectOptionsDropdownContext';
@@ -32,8 +32,8 @@ const meta: Meta<typeof ObjectOptionsDropdownContent> = {
decorators: [
(Story) => {
useEffect(() => {
jotaiStore.set(
objectMetadataItemsState.atom,
setTestObjectMetadataItemsInMetadataStore(
jotaiStore,
generatedMockObjectMetadataItems,
);
}, []);
@@ -1,7 +1,8 @@
import { type Meta, type StoryObj } from '@storybook/react-vite';
import { useEffect } from 'react';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { useMetadataStore } from '@/metadata-store/hooks/useMetadataStore';
import { splitObjectMetadataItemWithRelated } from '@/metadata-store/utils/splitObjectMetadataItemWithRelated';
import { getBasePathToShowPage } from '@/object-metadata/utils/getBasePathToShowPage';
import { FieldContext } from '@/object-record/record-field/ui/contexts/FieldContext';
@@ -15,7 +16,6 @@ import { getProfilingStory } from '~/testing/profiling/utils/getProfilingStory';
import { labelIdentifierFieldMetadataItemSelector } from '@/object-metadata/states/labelIdentifierFieldMetadataItemSelector';
import { useAtomFamilySelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomFamilySelectorValue';
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { formatFieldMetadataItemAsColumnDefinition } from '@/object-metadata/utils/formatFieldMetadataItemAsColumnDefinition';
import { RecordComponentInstanceContextsWrapper } from '@/object-record/components/RecordComponentInstanceContextsWrapper';
@@ -53,7 +53,7 @@ const RelationFieldValueSetterEffect = () => {
'recordTableId',
);
const [, setObjectMetadataItems] = useAtomState(objectMetadataItemsState);
const { updateDraft, applyChanges } = useMetadataStore();
useEffect(() => {
setRecordStore(mockPerformance.entityValue);
@@ -71,11 +71,18 @@ const RelationFieldValueSetterEffect = () => {
),
);
setObjectMetadataItems(generatedMockObjectMetadataItems);
const { flatObjects, flatFields, flatIndexes } =
splitObjectMetadataItemWithRelated(generatedMockObjectMetadataItems);
updateDraft('objectMetadataItems', flatObjects);
updateDraft('fieldMetadataItems', flatFields);
updateDraft('indexMetadataItems', flatIndexes);
applyChanges();
}, [
setRecordStore,
setRelationRecordStore,
setObjectMetadataItems,
updateDraft,
applyChanges,
setCurrentRecordFields,
]);
@@ -1,8 +1,8 @@
import { act, renderHook } from '@testing-library/react';
import { Provider as JotaiProvider } from 'jotai';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { CoreObjectNameSingular } from 'twenty-shared/types';
import { setTestObjectMetadataItemsInMetadataStore } from '~/testing/utils/setTestObjectMetadataItemsInMetadataStore';
import { RecordComponentInstanceContextsWrapper } from '@/object-record/components/RecordComponentInstanceContextsWrapper';
import { textfieldDefinition } from '@/object-record/record-field/ui/__mocks__/fieldDefinitions';
import { FieldContext } from '@/object-record/record-field/ui/contexts/FieldContext';
@@ -26,8 +26,8 @@ import { generatedMockObjectMetadataItems } from '~/testing/utils/generatedMockO
const recordTableId = 'record-table-id';
const Wrapper = ({ children }: { children: React.ReactNode }) => {
jotaiStore.set(
objectMetadataItemsState.atom,
setTestObjectMetadataItemsInMetadataStore(
jotaiStore,
generatedMockObjectMetadataItems,
);
@@ -1,8 +1,8 @@
import { act, renderHook } from '@testing-library/react';
import { Provider as JotaiProvider } from 'jotai';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { CoreObjectNameSingular } from 'twenty-shared/types';
import { setTestObjectMetadataItemsInMetadataStore } from '~/testing/utils/setTestObjectMetadataItemsInMetadataStore';
import { RecordComponentInstanceContextsWrapper } from '@/object-record/components/RecordComponentInstanceContextsWrapper';
import { textfieldDefinition } from '@/object-record/record-field/ui/__mocks__/fieldDefinitions';
import { FieldContext } from '@/object-record/record-field/ui/contexts/FieldContext';
@@ -26,8 +26,8 @@ import { generatedMockObjectMetadataItems } from '~/testing/utils/generatedMockO
const recordTableId = 'record-table-id';
const Wrapper = ({ children }: { children: React.ReactNode }) => {
jotaiStore.set(
objectMetadataItemsState.atom,
setTestObjectMetadataItemsInMetadataStore(
jotaiStore,
generatedMockObjectMetadataItems,
);
@@ -4,8 +4,8 @@ import { type ReactNode } from 'react';
import { useIcons } from 'twenty-ui/display';
import { JestObjectMetadataItemSetter } from '~/testing/jest/JestObjectMetadataItemSetter';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { jotaiStore } from '@/ui/utilities/state/jotai/jotaiStore';
import { setTestObjectMetadataItemsInMetadataStore } from '~/testing/utils/setTestObjectMetadataItemsInMetadataStore';
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { type IndexMetadataItem } from '@/object-metadata/types/IndexMetadataItem';
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
@@ -34,7 +34,7 @@ describe('useBuildSpreadSheetImportFields', () => {
getIcons: () => ({}),
});
jest.clearAllMocks();
jotaiStore.set(objectMetadataItemsState.atom, []);
setTestObjectMetadataItemsInMetadataStore(jotaiStore, []);
});
const createMockFieldMetadataItem = (