Files
twenty/packages/twenty-front/src/modules/metadata-store/hooks/useMetadataStore.ts
T
Charles Bochet 40ff109179 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
2026-03-14 12:54:19 +01:00

122 lines
3.3 KiB
TypeScript

import { isAppMetadataReadyState } from '@/metadata-store/states/isAppMetadataReadyState';
import {
ALL_METADATA_ENTITY_KEYS,
metadataStoreState,
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';
type JotaiStore = ReturnType<typeof createStore>;
const EMPTY_ENTRY: MetadataStoreItem = {
current: [],
draft: [],
status: 'empty',
};
const areViewsConsistentWithObjects = (
viewsDraft: object[],
objectsCurrent: object[],
): boolean => {
const objectIds = new Set(
objectsCurrent.map((item) => (item as { id: string }).id),
);
return viewsDraft.every((view) =>
objectIds.has((view as { objectMetadataId: string }).objectMetadataId),
);
};
export const resetMetadataStore = (store: JotaiStore) => {
for (const key of ALL_METADATA_ENTITY_KEYS) {
store.set(metadataStoreState.atomFamily(key), EMPTY_ENTRY);
}
store.set(isAppMetadataReadyState.atom, false);
};
const changeMetadataEntityAsUpToDate = (
store: JotaiStore,
metadataEntityKey: MetadataEntityKey,
) => {
const entry = store.get(metadataStoreState.atomFamily(metadataEntityKey));
store.set(metadataStoreState.atomFamily(metadataEntityKey), {
current: entry.draft,
draft: [],
status: 'up-to-date',
});
};
export const useMetadataStore = () => {
const store = useStore();
const updateDraft = useCallback(
<K extends MetadataEntityKey>(key: K, data: MetadataEntityTypeMap[K][]) => {
const currentEntry = store.get(metadataStoreState.atomFamily(key));
if (
currentEntry.status === 'up-to-date' &&
isDeeplyEqual(currentEntry.current, data)
) {
return;
}
store.set(metadataStoreState.atomFamily(key), (prev) => ({
...prev,
draft: data,
status: 'draft-pending' as const,
}));
},
[store],
);
const applyChanges = useCallback((): {
hasPersistedAnyMetadataEntity: boolean;
} => {
let hasPersistedAnyMetadataEntity = false;
for (const metadataEntityKey of ALL_METADATA_ENTITY_KEYS) {
if (metadataEntityKey === 'views') {
continue;
}
const metadataStoreEntityEntry = store.get(
metadataStoreState.atomFamily(metadataEntityKey),
);
if (metadataStoreEntityEntry.status === 'draft-pending') {
changeMetadataEntityAsUpToDate(store, metadataEntityKey);
hasPersistedAnyMetadataEntity = true;
}
}
const viewsEntry = store.get(metadataStoreState.atomFamily('views'));
if (viewsEntry.status === 'draft-pending') {
const objectsEntry = store.get(
metadataStoreState.atomFamily('objectMetadataItems'),
);
if (
areViewsConsistentWithObjects(viewsEntry.draft, objectsEntry.current)
) {
changeMetadataEntityAsUpToDate(store, 'views');
hasPersistedAnyMetadataEntity = true;
}
}
return { hasPersistedAnyMetadataEntity };
}, [store]);
const reset = useCallback(() => {
resetMetadataStore(store);
}, [store]);
return { updateDraft, applyChanges, resetMetadataStore: reset };
};