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
@@ -1,5 +1,5 @@
|
||||
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
|
||||
import { isAppMetadataReadyState } from '@/metadata-store/states/isAppMetadataReadyState';
|
||||
import { setTestObjectMetadataItemsInMetadataStore } from '~/testing/utils/setTestObjectMetadataItemsInMetadataStore';
|
||||
import { jotaiStore } from '@/ui/utilities/state/jotai/jotaiStore';
|
||||
import { PageLayoutContentProvider } from '@/page-layout/contexts/PageLayoutContentContext';
|
||||
import {
|
||||
@@ -33,8 +33,8 @@ const meta: Meta<typeof DashboardWidgetPlaceholder> = {
|
||||
component: DashboardWidgetPlaceholder,
|
||||
decorators: [
|
||||
(Story) => {
|
||||
jotaiStore.set(
|
||||
objectMetadataItemsState.atom,
|
||||
setTestObjectMetadataItemsInMetadataStore(
|
||||
jotaiStore,
|
||||
generatedMockObjectMetadataItems,
|
||||
);
|
||||
jotaiStore.set(isAppMetadataReadyState.atom, true);
|
||||
|
||||
+29
-29
@@ -8,8 +8,8 @@ import { CatalogDecorator, type CatalogStory } from 'twenty-ui/testing';
|
||||
|
||||
import { currentUserWorkspaceState } from '@/auth/states/currentUserWorkspaceState';
|
||||
import { ApolloCoreClientContext } from '@/object-metadata/contexts/ApolloCoreClientContext';
|
||||
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
|
||||
import { isAppMetadataReadyState } from '@/metadata-store/states/isAppMetadataReadyState';
|
||||
import { setTestObjectMetadataItemsInMetadataStore } from '~/testing/utils/setTestObjectMetadataItemsInMetadataStore';
|
||||
import { jotaiStore } from '@/ui/utilities/state/jotai/jotaiStore';
|
||||
import { CoreObjectNameSingular } from 'twenty-shared/types';
|
||||
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
|
||||
@@ -277,8 +277,8 @@ export const WithNumberChart: Story = {
|
||||
deletedAt: null,
|
||||
};
|
||||
|
||||
jotaiStore.set(
|
||||
objectMetadataItemsState.atom,
|
||||
setTestObjectMetadataItemsInMetadataStore(
|
||||
jotaiStore,
|
||||
generatedMockObjectMetadataItems,
|
||||
);
|
||||
jotaiStore.set(isAppMetadataReadyState.atom, true);
|
||||
@@ -364,8 +364,8 @@ export const WithGaugeChart: Story = {
|
||||
deletedAt: null,
|
||||
};
|
||||
|
||||
jotaiStore.set(
|
||||
objectMetadataItemsState.atom,
|
||||
setTestObjectMetadataItemsInMetadataStore(
|
||||
jotaiStore,
|
||||
generatedMockObjectMetadataItems,
|
||||
);
|
||||
jotaiStore.set(isAppMetadataReadyState.atom, true);
|
||||
@@ -455,8 +455,8 @@ export const WithBarChart: Story = {
|
||||
deletedAt: null,
|
||||
};
|
||||
|
||||
jotaiStore.set(
|
||||
objectMetadataItemsState.atom,
|
||||
setTestObjectMetadataItemsInMetadataStore(
|
||||
jotaiStore,
|
||||
generatedMockObjectMetadataItems,
|
||||
);
|
||||
jotaiStore.set(isAppMetadataReadyState.atom, true);
|
||||
@@ -549,8 +549,8 @@ export const SmallWidget: Story = {
|
||||
deletedAt: null,
|
||||
};
|
||||
|
||||
jotaiStore.set(
|
||||
objectMetadataItemsState.atom,
|
||||
setTestObjectMetadataItemsInMetadataStore(
|
||||
jotaiStore,
|
||||
generatedMockObjectMetadataItems,
|
||||
);
|
||||
jotaiStore.set(isAppMetadataReadyState.atom, true);
|
||||
@@ -647,8 +647,8 @@ export const MediumWidget: Story = {
|
||||
deletedAt: null,
|
||||
};
|
||||
|
||||
jotaiStore.set(
|
||||
objectMetadataItemsState.atom,
|
||||
setTestObjectMetadataItemsInMetadataStore(
|
||||
jotaiStore,
|
||||
generatedMockObjectMetadataItems,
|
||||
);
|
||||
jotaiStore.set(isAppMetadataReadyState.atom, true);
|
||||
@@ -745,8 +745,8 @@ export const LargeWidget: Story = {
|
||||
deletedAt: null,
|
||||
};
|
||||
|
||||
jotaiStore.set(
|
||||
objectMetadataItemsState.atom,
|
||||
setTestObjectMetadataItemsInMetadataStore(
|
||||
jotaiStore,
|
||||
generatedMockObjectMetadataItems,
|
||||
);
|
||||
jotaiStore.set(isAppMetadataReadyState.atom, true);
|
||||
@@ -839,8 +839,8 @@ export const WideWidget: Story = {
|
||||
deletedAt: null,
|
||||
};
|
||||
|
||||
jotaiStore.set(
|
||||
objectMetadataItemsState.atom,
|
||||
setTestObjectMetadataItemsInMetadataStore(
|
||||
jotaiStore,
|
||||
generatedMockObjectMetadataItems,
|
||||
);
|
||||
jotaiStore.set(isAppMetadataReadyState.atom, true);
|
||||
@@ -937,8 +937,8 @@ export const TallWidget: Story = {
|
||||
deletedAt: null,
|
||||
};
|
||||
|
||||
jotaiStore.set(
|
||||
objectMetadataItemsState.atom,
|
||||
setTestObjectMetadataItemsInMetadataStore(
|
||||
jotaiStore,
|
||||
generatedMockObjectMetadataItems,
|
||||
);
|
||||
jotaiStore.set(isAppMetadataReadyState.atom, true);
|
||||
@@ -1031,8 +1031,8 @@ export const WithManyToOneRelationFieldWidget: Story = {
|
||||
deletedAt: null,
|
||||
};
|
||||
|
||||
jotaiStore.set(
|
||||
objectMetadataItemsState.atom,
|
||||
setTestObjectMetadataItemsInMetadataStore(
|
||||
jotaiStore,
|
||||
generatedMockObjectMetadataItems,
|
||||
);
|
||||
jotaiStore.set(isAppMetadataReadyState.atom, true);
|
||||
@@ -1150,8 +1150,8 @@ export const WithOneToManyRelationFieldWidget: Story = {
|
||||
deletedAt: null,
|
||||
};
|
||||
|
||||
jotaiStore.set(
|
||||
objectMetadataItemsState.atom,
|
||||
setTestObjectMetadataItemsInMetadataStore(
|
||||
jotaiStore,
|
||||
generatedMockObjectMetadataItems,
|
||||
);
|
||||
jotaiStore.set(isAppMetadataReadyState.atom, true);
|
||||
@@ -1261,8 +1261,8 @@ export const OneToManyRelationFieldWidgetWithSeeAllButton: Story = {
|
||||
deletedAt: null,
|
||||
};
|
||||
|
||||
jotaiStore.set(
|
||||
objectMetadataItemsState.atom,
|
||||
setTestObjectMetadataItemsInMetadataStore(
|
||||
jotaiStore,
|
||||
generatedMockObjectMetadataItems,
|
||||
);
|
||||
jotaiStore.set(isAppMetadataReadyState.atom, true);
|
||||
@@ -1399,8 +1399,8 @@ export const OnMobile: Story = {
|
||||
deletedAt: null,
|
||||
};
|
||||
|
||||
jotaiStore.set(
|
||||
objectMetadataItemsState.atom,
|
||||
setTestObjectMetadataItemsInMetadataStore(
|
||||
jotaiStore,
|
||||
generatedMockObjectMetadataItems,
|
||||
);
|
||||
jotaiStore.set(isAppMetadataReadyState.atom, true);
|
||||
@@ -1497,8 +1497,8 @@ export const InSidePanel: Story = {
|
||||
deletedAt: null,
|
||||
};
|
||||
|
||||
jotaiStore.set(
|
||||
objectMetadataItemsState.atom,
|
||||
setTestObjectMetadataItemsInMetadataStore(
|
||||
jotaiStore,
|
||||
generatedMockObjectMetadataItems,
|
||||
);
|
||||
jotaiStore.set(isAppMetadataReadyState.atom, true);
|
||||
@@ -1656,8 +1656,8 @@ export const Catalog: CatalogStory<Story, typeof WidgetRenderer> = {
|
||||
deletedAt: null,
|
||||
};
|
||||
|
||||
jotaiStore.set(
|
||||
objectMetadataItemsState.atom,
|
||||
setTestObjectMetadataItemsInMetadataStore(
|
||||
jotaiStore,
|
||||
generatedMockObjectMetadataItems,
|
||||
);
|
||||
jotaiStore.set(isAppMetadataReadyState.atom, true);
|
||||
|
||||
+35
-35
@@ -5,8 +5,8 @@ import { MemoryRouter } from 'react-router-dom';
|
||||
import { expect, userEvent, waitFor, within } from 'storybook/test';
|
||||
|
||||
import { ApolloCoreClientContext } from '@/object-metadata/contexts/ApolloCoreClientContext';
|
||||
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
|
||||
import { isAppMetadataReadyState } from '@/metadata-store/states/isAppMetadataReadyState';
|
||||
import { setTestObjectMetadataItemsInMetadataStore } from '~/testing/utils/setTestObjectMetadataItemsInMetadataStore';
|
||||
import { CoreObjectNameSingular } from 'twenty-shared/types';
|
||||
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
|
||||
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
|
||||
@@ -356,8 +356,8 @@ export const TextFieldWidget: Story = {
|
||||
deletedAt: null,
|
||||
};
|
||||
|
||||
jotaiStore.set(
|
||||
objectMetadataItemsState.atom,
|
||||
setTestObjectMetadataItemsInMetadataStore(
|
||||
jotaiStore,
|
||||
generatedMockObjectMetadataItems,
|
||||
);
|
||||
jotaiStore.set(isAppMetadataReadyState.atom, true);
|
||||
@@ -450,8 +450,8 @@ export const AddressFieldWidget: Story = {
|
||||
deletedAt: null,
|
||||
};
|
||||
|
||||
jotaiStore.set(
|
||||
objectMetadataItemsState.atom,
|
||||
setTestObjectMetadataItemsInMetadataStore(
|
||||
jotaiStore,
|
||||
generatedMockObjectMetadataItems,
|
||||
);
|
||||
jotaiStore.set(isAppMetadataReadyState.atom, true);
|
||||
@@ -547,8 +547,8 @@ export const NumberFieldWidget: Story = {
|
||||
deletedAt: null,
|
||||
};
|
||||
|
||||
jotaiStore.set(
|
||||
objectMetadataItemsState.atom,
|
||||
setTestObjectMetadataItemsInMetadataStore(
|
||||
jotaiStore,
|
||||
generatedMockObjectMetadataItems,
|
||||
);
|
||||
jotaiStore.set(isAppMetadataReadyState.atom, true);
|
||||
@@ -641,8 +641,8 @@ export const LinkFieldWidget: Story = {
|
||||
deletedAt: null,
|
||||
};
|
||||
|
||||
jotaiStore.set(
|
||||
objectMetadataItemsState.atom,
|
||||
setTestObjectMetadataItemsInMetadataStore(
|
||||
jotaiStore,
|
||||
generatedMockObjectMetadataItems,
|
||||
);
|
||||
jotaiStore.set(isAppMetadataReadyState.atom, true);
|
||||
@@ -735,8 +735,8 @@ export const ManyToOneRelationFieldWidget: Story = {
|
||||
deletedAt: null,
|
||||
};
|
||||
|
||||
jotaiStore.set(
|
||||
objectMetadataItemsState.atom,
|
||||
setTestObjectMetadataItemsInMetadataStore(
|
||||
jotaiStore,
|
||||
generatedMockObjectMetadataItems,
|
||||
);
|
||||
jotaiStore.set(isAppMetadataReadyState.atom, true);
|
||||
@@ -839,8 +839,8 @@ export const OneToManyRelationFieldWidget: Story = {
|
||||
deletedAt: null,
|
||||
};
|
||||
|
||||
jotaiStore.set(
|
||||
objectMetadataItemsState.atom,
|
||||
setTestObjectMetadataItemsInMetadataStore(
|
||||
jotaiStore,
|
||||
generatedMockObjectMetadataItems,
|
||||
);
|
||||
jotaiStore.set(isAppMetadataReadyState.atom, true);
|
||||
@@ -935,8 +935,8 @@ export const BooleanFieldWidget: Story = {
|
||||
deletedAt: null,
|
||||
};
|
||||
|
||||
jotaiStore.set(
|
||||
objectMetadataItemsState.atom,
|
||||
setTestObjectMetadataItemsInMetadataStore(
|
||||
jotaiStore,
|
||||
generatedMockObjectMetadataItems,
|
||||
);
|
||||
jotaiStore.set(isAppMetadataReadyState.atom, true);
|
||||
@@ -1028,8 +1028,8 @@ export const CurrencyFieldWidget: Story = {
|
||||
deletedAt: null,
|
||||
};
|
||||
|
||||
jotaiStore.set(
|
||||
objectMetadataItemsState.atom,
|
||||
setTestObjectMetadataItemsInMetadataStore(
|
||||
jotaiStore,
|
||||
generatedMockObjectMetadataItems,
|
||||
);
|
||||
jotaiStore.set(isAppMetadataReadyState.atom, true);
|
||||
@@ -1121,8 +1121,8 @@ export const EmailsFieldWidget: Story = {
|
||||
deletedAt: null,
|
||||
};
|
||||
|
||||
jotaiStore.set(
|
||||
objectMetadataItemsState.atom,
|
||||
setTestObjectMetadataItemsInMetadataStore(
|
||||
jotaiStore,
|
||||
generatedMockObjectMetadataItems,
|
||||
);
|
||||
jotaiStore.set(isAppMetadataReadyState.atom, true);
|
||||
@@ -1215,8 +1215,8 @@ export const PhonesFieldWidget: Story = {
|
||||
deletedAt: null,
|
||||
};
|
||||
|
||||
jotaiStore.set(
|
||||
objectMetadataItemsState.atom,
|
||||
setTestObjectMetadataItemsInMetadataStore(
|
||||
jotaiStore,
|
||||
generatedMockObjectMetadataItems,
|
||||
);
|
||||
jotaiStore.set(isAppMetadataReadyState.atom, true);
|
||||
@@ -1309,8 +1309,8 @@ export const SelectFieldWidget: Story = {
|
||||
deletedAt: null,
|
||||
};
|
||||
|
||||
jotaiStore.set(
|
||||
objectMetadataItemsState.atom,
|
||||
setTestObjectMetadataItemsInMetadataStore(
|
||||
jotaiStore,
|
||||
generatedMockObjectMetadataItems,
|
||||
);
|
||||
jotaiStore.set(isAppMetadataReadyState.atom, true);
|
||||
@@ -1404,8 +1404,8 @@ export const MultiSelectFieldWidget: Story = {
|
||||
deletedAt: null,
|
||||
};
|
||||
|
||||
jotaiStore.set(
|
||||
objectMetadataItemsState.atom,
|
||||
setTestObjectMetadataItemsInMetadataStore(
|
||||
jotaiStore,
|
||||
generatedMockObjectMetadataItems,
|
||||
);
|
||||
jotaiStore.set(isAppMetadataReadyState.atom, true);
|
||||
@@ -1502,8 +1502,8 @@ export const TimelineActivityRelationFieldWidget: Story = {
|
||||
deletedAt: null,
|
||||
};
|
||||
|
||||
jotaiStore.set(
|
||||
objectMetadataItemsState.atom,
|
||||
setTestObjectMetadataItemsInMetadataStore(
|
||||
jotaiStore,
|
||||
generatedMockObjectMetadataItems,
|
||||
);
|
||||
jotaiStore.set(isAppMetadataReadyState.atom, true);
|
||||
@@ -1601,8 +1601,8 @@ export const ManyToOneRelationCardWidget: Story = {
|
||||
deletedAt: null,
|
||||
};
|
||||
|
||||
jotaiStore.set(
|
||||
objectMetadataItemsState.atom,
|
||||
setTestObjectMetadataItemsInMetadataStore(
|
||||
jotaiStore,
|
||||
generatedMockObjectMetadataItems,
|
||||
);
|
||||
jotaiStore.set(isAppMetadataReadyState.atom, true);
|
||||
@@ -1713,8 +1713,8 @@ export const OneToManyRelationCardWidget: Story = {
|
||||
deletedAt: null,
|
||||
};
|
||||
|
||||
jotaiStore.set(
|
||||
objectMetadataItemsState.atom,
|
||||
setTestObjectMetadataItemsInMetadataStore(
|
||||
jotaiStore,
|
||||
generatedMockObjectMetadataItems,
|
||||
);
|
||||
jotaiStore.set(isAppMetadataReadyState.atom, true);
|
||||
@@ -1808,8 +1808,8 @@ export const TimelineActivityRelationCardWidget: Story = {
|
||||
deletedAt: null,
|
||||
};
|
||||
|
||||
jotaiStore.set(
|
||||
objectMetadataItemsState.atom,
|
||||
setTestObjectMetadataItemsInMetadataStore(
|
||||
jotaiStore,
|
||||
generatedMockObjectMetadataItems,
|
||||
);
|
||||
jotaiStore.set(isAppMetadataReadyState.atom, true);
|
||||
@@ -1969,8 +1969,8 @@ export const OneToManyRelationCardWidgetWithProgressiveLoading: Story = {
|
||||
deletedAt: null,
|
||||
};
|
||||
|
||||
jotaiStore.set(
|
||||
objectMetadataItemsState.atom,
|
||||
setTestObjectMetadataItemsInMetadataStore(
|
||||
jotaiStore,
|
||||
generatedMockObjectMetadataItems,
|
||||
);
|
||||
jotaiStore.set(isAppMetadataReadyState.atom, true);
|
||||
|
||||
+7
-7
@@ -6,8 +6,8 @@ import { expect, waitFor, within } from 'storybook/test';
|
||||
|
||||
import { isAppMetadataReadyState } from '@/metadata-store/states/isAppMetadataReadyState';
|
||||
import { ApolloCoreClientContext } from '@/object-metadata/contexts/ApolloCoreClientContext';
|
||||
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
|
||||
import { CoreObjectNameSingular } from 'twenty-shared/types';
|
||||
import { setTestObjectMetadataItemsInMetadataStore } from '~/testing/utils/setTestObjectMetadataItemsInMetadataStore';
|
||||
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
|
||||
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
|
||||
import { PageLayoutContentProvider } from '@/page-layout/contexts/PageLayoutContentContext';
|
||||
@@ -321,8 +321,8 @@ export const WithViewFieldGroups: Story = {
|
||||
companyObjectMetadataItem.id,
|
||||
);
|
||||
|
||||
jotaiStore.set(
|
||||
objectMetadataItemsState.atom,
|
||||
setTestObjectMetadataItemsInMetadataStore(
|
||||
jotaiStore,
|
||||
generatedMockObjectMetadataItems,
|
||||
);
|
||||
jotaiStore.set(isAppMetadataReadyState.atom, true);
|
||||
@@ -434,8 +434,8 @@ export const WithInlineViewFields: Story = {
|
||||
companyObjectMetadataItem.id,
|
||||
);
|
||||
|
||||
jotaiStore.set(
|
||||
objectMetadataItemsState.atom,
|
||||
setTestObjectMetadataItemsInMetadataStore(
|
||||
jotaiStore,
|
||||
generatedMockObjectMetadataItems,
|
||||
);
|
||||
jotaiStore.set(isAppMetadataReadyState.atom, true);
|
||||
@@ -526,8 +526,8 @@ export const Empty: Story = {
|
||||
companyObjectMetadataItem.id,
|
||||
);
|
||||
|
||||
jotaiStore.set(
|
||||
objectMetadataItemsState.atom,
|
||||
setTestObjectMetadataItemsInMetadataStore(
|
||||
jotaiStore,
|
||||
generatedMockObjectMetadataItems,
|
||||
);
|
||||
jotaiStore.set(isAppMetadataReadyState.atom, true);
|
||||
|
||||
Reference in New Issue
Block a user