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:
+3
-3
@@ -6,7 +6,6 @@ import { objectFilterDropdownFilterIsSelectedComponentState } from '@/object-rec
|
||||
import { selectedOperandInDropdownComponentState } from '@/object-record/object-filter-dropdown/states/selectedOperandInDropdownComponentState';
|
||||
import { useInitializeFilterOnFieldMetadataItemFromViewBarFilterDropdown } from '@/views/hooks/useInitializeFilterOnFieldMetadataItemFromViewBarFilterDropdown';
|
||||
|
||||
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
|
||||
import {
|
||||
jotaiStore,
|
||||
resetJotaiStore,
|
||||
@@ -26,6 +25,7 @@ import { ViewBarFilterDropdownIds } from '@/views/constants/ViewBarFilterDropdow
|
||||
import { getFilterTypeFromFieldType } from 'twenty-shared/utils';
|
||||
import { generatedMockObjectMetadataItems } from '~/testing/utils/generatedMockObjectMetadataItems';
|
||||
import { getMockObjectMetadataItemOrThrow } from '~/testing/utils/getMockObjectMetadataItemOrThrow';
|
||||
import { setTestObjectMetadataItemsInMetadataStore } from '~/testing/utils/setTestObjectMetadataItemsInMetadataStore';
|
||||
|
||||
const mockPushFocusItemToFocusStack = jest.fn();
|
||||
|
||||
@@ -46,8 +46,8 @@ const personCreatedAtFieldMetadataItemMock =
|
||||
);
|
||||
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => {
|
||||
jotaiStore.set(
|
||||
objectMetadataItemsState.atom,
|
||||
setTestObjectMetadataItemsInMetadataStore(
|
||||
jotaiStore,
|
||||
generatedMockObjectMetadataItems,
|
||||
);
|
||||
return (
|
||||
|
||||
+3
-3
@@ -1,5 +1,5 @@
|
||||
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
|
||||
import { jotaiStore } from '@/ui/utilities/state/jotai/jotaiStore';
|
||||
import { setTestObjectMetadataItemsInMetadataStore } from '~/testing/utils/setTestObjectMetadataItemsInMetadataStore';
|
||||
import { ViewComponentInstanceContext } from '@/views/states/contexts/ViewComponentInstanceContext';
|
||||
import { viewObjectMetadataIdComponentState } from '@/views/states/viewObjectMetadataIdComponentState';
|
||||
import { useGetAvailableFieldsForCalendar } from '@/views/view-picker/hooks/useGetAvailableFieldsForCalendar';
|
||||
@@ -22,7 +22,7 @@ const createMockObjectMetadataItem = (fields: any[]) => ({
|
||||
|
||||
const createWrapper = (objectMetadataItems: any[]) => {
|
||||
return ({ children }: { children: ReactNode }) => {
|
||||
jotaiStore.set(objectMetadataItemsState.atom, objectMetadataItems);
|
||||
setTestObjectMetadataItemsInMetadataStore(jotaiStore, objectMetadataItems);
|
||||
jotaiStore.set(
|
||||
viewObjectMetadataIdComponentState.atomFamily({
|
||||
instanceId: mockViewInstanceId,
|
||||
@@ -80,7 +80,7 @@ describe('useGetAvailableFieldsForCalendar', () => {
|
||||
});
|
||||
|
||||
expect(result.current.availableFieldsForCalendar).toHaveLength(2);
|
||||
expect(result.current.availableFieldsForCalendar).toEqual([
|
||||
expect(result.current.availableFieldsForCalendar).toMatchObject([
|
||||
{
|
||||
id: '1',
|
||||
type: FieldMetadataType.DATE,
|
||||
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
|
||||
import { jotaiStore } from '@/ui/utilities/state/jotai/jotaiStore';
|
||||
import { setTestObjectMetadataItemsInMetadataStore } from '~/testing/utils/setTestObjectMetadataItemsInMetadataStore';
|
||||
import { ViewComponentInstanceContext } from '@/views/states/contexts/ViewComponentInstanceContext';
|
||||
import { viewObjectMetadataIdComponentState } from '@/views/states/viewObjectMetadataIdComponentState';
|
||||
import { useGetAvailableFieldsToGroupRecordsBy } from '@/views/view-picker/hooks/useGetAvailableFieldsToGroupRecordsBy';
|
||||
@@ -22,7 +22,7 @@ const createMockObjectMetadataItem = (fields: any[]) => ({
|
||||
|
||||
const createWrapper = (objectMetadataItems: any[]) => {
|
||||
return ({ children }: { children: ReactNode }) => {
|
||||
jotaiStore.set(objectMetadataItemsState.atom, objectMetadataItems);
|
||||
setTestObjectMetadataItemsInMetadataStore(jotaiStore, objectMetadataItems);
|
||||
jotaiStore.set(
|
||||
viewObjectMetadataIdComponentState.atomFamily({
|
||||
instanceId: mockViewInstanceId,
|
||||
|
||||
Reference in New Issue
Block a user