refactor(server): unify the two metadata override mechanisms into one (#22417)
## Unify the two metadata override mechanisms into one Twenty had **two** override mechanisms: - **`standardOverrides`** — a bespoke JSONB column on `objectMetadata`/`fieldMetadata` with typed DTOs and a per-locale `translations` map, resolved by two i18n-aware resolvers. - **`OverridableEntity.overrides`** — a flat, registry-driven JSONB blob on view / view-field / view-field-group / command-menu-item / page-layout-tab / page-layout-widget, resolved by a plain spread. This PR collapses them into **one** concept: a single `overrides` blob, one registry-driven overridable set, one i18n-aware read path, and one write path (`computeMetadataOverridesBlob`, extracted in #22404). Object/field **stay on `SyncableEntity`** (not reparented to `OverridableEntity`) so their `isActive` default stays **FALSE** — this sidesteps the `isActive` default conflict entirely. ### GraphQL breaking change (accepted) The `standardOverrides` field is **removed** with no deprecation alias — `overrides` (a `JSON` scalar) is exposed instead on `Object` and `Field`. Product confirmed negligible external usage; the front-end has no hand-written consumer (only generated types), which are regenerated here. ### Commit structure (reviewable commit-by-commit) 1. **Unified resolver + parity harness** — `resolveEffectiveEntityProperty` is a strict superset of the three legacy resolvers; a corpus parity spec compares it against a *frozen reference* of the old logic across every locale, `isStandardApp` branch and override shape. 2. **Registry-driven** — object/field presentation props tagged `isOverridable` + `translatable`; the overridable/translatable sets are derived from the registry (a test asserts they equal the legacy hardcoded lists). 3. **Rename + swap + delete** — `standardOverrides` → `overrides` across entities, DTOs, flat/universal types, producers, the ~12 resolve/write/create/sync call sites, mocks and specs; the reconciler's two compare entries collapse to one; the three legacy resolvers, both DTOs and the hardcoded constants/types are deleted. 4. **Migration (zero-downtime, two-phase)** — split across two releases so a rolling deploy never drops a column a previous-release pod still `SELECT`s: - **2.19 fast** — add the `overrides` column (schema only). - **2.19 slow** — backfill `overrides` from `standardOverrides` in `runDataMigration` (kept out of the schema transaction so the bulk write doesn't hold the ACCESS EXCLUSIVE lock; skipped on fresh installs, which have no data to copy). - **2.20 fast** — drop the legacy `standardOverrides` column (gated by `TWENTY_NEXT_VERSIONS`, so it stays dormant until the instance reaches 2.20). 5. **Front/client-SDK regen** — regenerated metadata GraphQL types. 6. **Integration specs + i18n** — updated the standard object/field update integration specs + snapshots, and the reworded validator message catalog entry. ### Rolling-deploy safety `standardOverrides` is retained through 2.19 and only dropped in 2.20, mirroring the codebase's deferred-drop convention (`isUIReadOnly`/`isCustom`). During the 2.19 rollout both columns exist, so old and new pods coexist without "column does not exist" errors. The backfill lives in a slow `runDataMigration` (per the `no-data-mutation-in-fast-instance-command` rule) so it doesn't stall reads. ### `isActive` guard The migration never reads or writes `isActive`; the backfill asserts the active-row count is unchanged and aborts otherwise. Verified on a real DB: apply + revert preserves the blob **and** the nested `translations` map, with `isActive` counts identical before/after. ### Verification (local) - `nx typecheck twenty-server` + `nx typecheck twenty-front` — green - `nx lint:diff-with-main twenty-server` (oxlint `--type-aware` + oxfmt) — green - `nx test twenty-server` — green (unit + parity + registry + migration tests) - `nx run twenty-server:test:integration:with-db-reset` — green - `database:reset` applies the 2.19 phases and leaves **both** columns present (2.20 drop stays dormant); backfill + revert round-trip verified on a real DB - Metadata integration suites (standard object/field update, application sync) pass end-to-end against the two-column schema - Metadata GraphQL types regenerated against a booted server; zero `standardOverrides` references remain in application code (only the migration commands + the legacy schema baseline) --------- Co-authored-by: prastoin <paul@twenty.com>
This commit is contained in:
+4
-4
@@ -72,7 +72,7 @@ exports[`ALL_UNIVERSAL_FLAT_ENTITY_PROPERTIES_TO_COMPARE_AND_STRINGIFY should ma
|
||||
"label",
|
||||
"name",
|
||||
"options",
|
||||
"standardOverrides",
|
||||
"overrides",
|
||||
"universalSettings",
|
||||
"isUIEditable",
|
||||
"isNullable",
|
||||
@@ -80,7 +80,7 @@ exports[`ALL_UNIVERSAL_FLAT_ENTITY_PROPERTIES_TO_COMPARE_AND_STRINGIFY should ma
|
||||
"propertiesToStringify": [
|
||||
"defaultValue",
|
||||
"options",
|
||||
"standardOverrides",
|
||||
"overrides",
|
||||
"universalSettings",
|
||||
],
|
||||
},
|
||||
@@ -171,14 +171,14 @@ exports[`ALL_UNIVERSAL_FLAT_ENTITY_PROPERTIES_TO_COMPARE_AND_STRINGIFY should ma
|
||||
"namePlural",
|
||||
"nameSingular",
|
||||
"labelIdentifierFieldMetadataUniversalIdentifier",
|
||||
"standardOverrides",
|
||||
"overrides",
|
||||
"isUIEditable",
|
||||
"isUICreatable",
|
||||
"isSearchable",
|
||||
"imageIdentifierFieldMetadataUniversalIdentifier",
|
||||
],
|
||||
"propertiesToStringify": [
|
||||
"standardOverrides",
|
||||
"overrides",
|
||||
],
|
||||
},
|
||||
"objectPermission": {
|
||||
|
||||
+137
@@ -0,0 +1,137 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`registry-derived override property maps derives the overridable properties for every metadata entity 1`] = `
|
||||
{
|
||||
"agent": [],
|
||||
"applicationVariable": [],
|
||||
"commandMenuItem": [
|
||||
"label",
|
||||
"icon",
|
||||
"shortLabel",
|
||||
"position",
|
||||
"isPinned",
|
||||
"availabilityType",
|
||||
"availabilityObjectMetadataId",
|
||||
"engineComponentKey",
|
||||
"hotKeys",
|
||||
"pageLayoutId",
|
||||
],
|
||||
"connectionProvider": [],
|
||||
"fieldMetadata": [
|
||||
"description",
|
||||
"icon",
|
||||
"label",
|
||||
],
|
||||
"fieldPermission": [],
|
||||
"frontComponent": [],
|
||||
"index": [],
|
||||
"logicFunction": [],
|
||||
"navigationMenuItem": [],
|
||||
"objectMetadata": [
|
||||
"color",
|
||||
"description",
|
||||
"icon",
|
||||
"labelPlural",
|
||||
"labelSingular",
|
||||
],
|
||||
"objectPermission": [],
|
||||
"pageLayout": [],
|
||||
"pageLayoutTab": [
|
||||
"title",
|
||||
"position",
|
||||
"icon",
|
||||
],
|
||||
"pageLayoutWidget": [
|
||||
"title",
|
||||
"position",
|
||||
"pageLayoutTabId",
|
||||
"conditionalDisplay",
|
||||
"conditionalAvailabilityExpression",
|
||||
],
|
||||
"permissionFlag": [],
|
||||
"role": [],
|
||||
"rolePermissionFlag": [],
|
||||
"roleTarget": [],
|
||||
"rowLevelPermissionPredicate": [],
|
||||
"rowLevelPermissionPredicateGroup": [],
|
||||
"searchFieldMetadata": [],
|
||||
"skill": [],
|
||||
"view": [
|
||||
"name",
|
||||
"type",
|
||||
"icon",
|
||||
"position",
|
||||
"isCompact",
|
||||
"openRecordIn",
|
||||
"kanbanAggregateOperation",
|
||||
"kanbanAggregateOperationFieldMetadataId",
|
||||
"anyFieldFilterValue",
|
||||
"calendarLayout",
|
||||
"calendarFieldMetadataId",
|
||||
"visibility",
|
||||
"mainGroupByFieldMetadataId",
|
||||
"shouldHideEmptyGroups",
|
||||
"kanbanColumnWidth",
|
||||
],
|
||||
"viewField": [
|
||||
"isVisible",
|
||||
"size",
|
||||
"position",
|
||||
"aggregateOperation",
|
||||
"viewFieldGroupId",
|
||||
],
|
||||
"viewFieldGroup": [
|
||||
"name",
|
||||
"position",
|
||||
"isVisible",
|
||||
],
|
||||
"viewFilter": [],
|
||||
"viewFilterGroup": [],
|
||||
"viewGroup": [],
|
||||
"viewSort": [],
|
||||
"webhook": [],
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`registry-derived override property maps derives the translatable properties for every metadata entity 1`] = `
|
||||
{
|
||||
"agent": [],
|
||||
"applicationVariable": [],
|
||||
"commandMenuItem": [],
|
||||
"connectionProvider": [],
|
||||
"fieldMetadata": [
|
||||
"description",
|
||||
"label",
|
||||
],
|
||||
"fieldPermission": [],
|
||||
"frontComponent": [],
|
||||
"index": [],
|
||||
"logicFunction": [],
|
||||
"navigationMenuItem": [],
|
||||
"objectMetadata": [
|
||||
"description",
|
||||
"labelPlural",
|
||||
"labelSingular",
|
||||
],
|
||||
"objectPermission": [],
|
||||
"pageLayout": [],
|
||||
"pageLayoutTab": [],
|
||||
"pageLayoutWidget": [],
|
||||
"permissionFlag": [],
|
||||
"role": [],
|
||||
"rolePermissionFlag": [],
|
||||
"roleTarget": [],
|
||||
"rowLevelPermissionPredicate": [],
|
||||
"rowLevelPermissionPredicateGroup": [],
|
||||
"searchFieldMetadata": [],
|
||||
"skill": [],
|
||||
"view": [],
|
||||
"viewField": [],
|
||||
"viewFieldGroup": [],
|
||||
"viewFilter": [],
|
||||
"viewFilterGroup": [],
|
||||
"viewGroup": [],
|
||||
"viewSort": [],
|
||||
"webhook": [],
|
||||
}
|
||||
`;
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
import { ALL_OVERRIDABLE_PROPERTIES_BY_METADATA_NAME } from 'src/engine/metadata-modules/flat-entity/constant/all-overridable-properties-by-metadata-name.constant';
|
||||
import { ALL_TRANSLATABLE_PROPERTIES_BY_METADATA_NAME } from 'src/engine/metadata-modules/flat-entity/constant/all-translatable-properties-by-metadata-name.constant';
|
||||
|
||||
describe('registry-derived override property maps', () => {
|
||||
it('derives the overridable properties for every metadata entity', () => {
|
||||
expect(ALL_OVERRIDABLE_PROPERTIES_BY_METADATA_NAME).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('derives the translatable properties for every metadata entity', () => {
|
||||
expect(ALL_TRANSLATABLE_PROPERTIES_BY_METADATA_NAME).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
+35
-4
@@ -40,6 +40,7 @@ type MetadataEntityPropertyConfiguration<
|
||||
: boolean;
|
||||
toCompare: boolean;
|
||||
isOverridable?: boolean;
|
||||
translatable?: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -59,8 +60,15 @@ export const ALL_ENTITY_PROPERTIES_CONFIGURATION_BY_METADATA_NAME = {
|
||||
toCompare: true,
|
||||
toStringify: false,
|
||||
universalProperty: undefined,
|
||||
isOverridable: true,
|
||||
translatable: true,
|
||||
},
|
||||
icon: {
|
||||
toCompare: true,
|
||||
toStringify: false,
|
||||
universalProperty: undefined,
|
||||
isOverridable: true,
|
||||
},
|
||||
icon: { toCompare: true, toStringify: false, universalProperty: undefined },
|
||||
isActive: {
|
||||
toCompare: true,
|
||||
toStringify: false,
|
||||
@@ -86,6 +94,8 @@ export const ALL_ENTITY_PROPERTIES_CONFIGURATION_BY_METADATA_NAME = {
|
||||
toCompare: true,
|
||||
toStringify: false,
|
||||
universalProperty: undefined,
|
||||
isOverridable: true,
|
||||
translatable: true,
|
||||
},
|
||||
name: { toCompare: true, toStringify: false, universalProperty: undefined },
|
||||
options: {
|
||||
@@ -93,7 +103,7 @@ export const ALL_ENTITY_PROPERTIES_CONFIGURATION_BY_METADATA_NAME = {
|
||||
toStringify: true,
|
||||
universalProperty: undefined,
|
||||
},
|
||||
standardOverrides: {
|
||||
overrides: {
|
||||
toCompare: true,
|
||||
toStringify: true,
|
||||
universalProperty: undefined,
|
||||
@@ -164,13 +174,21 @@ export const ALL_ENTITY_PROPERTIES_CONFIGURATION_BY_METADATA_NAME = {
|
||||
toCompare: true,
|
||||
toStringify: false,
|
||||
universalProperty: undefined,
|
||||
isOverridable: true,
|
||||
},
|
||||
description: {
|
||||
toCompare: true,
|
||||
toStringify: false,
|
||||
universalProperty: undefined,
|
||||
isOverridable: true,
|
||||
translatable: true,
|
||||
},
|
||||
icon: {
|
||||
toCompare: true,
|
||||
toStringify: false,
|
||||
universalProperty: undefined,
|
||||
isOverridable: true,
|
||||
},
|
||||
icon: { toCompare: true, toStringify: false, universalProperty: undefined },
|
||||
isActive: {
|
||||
toCompare: true,
|
||||
toStringify: false,
|
||||
@@ -185,11 +203,15 @@ export const ALL_ENTITY_PROPERTIES_CONFIGURATION_BY_METADATA_NAME = {
|
||||
toCompare: true,
|
||||
toStringify: false,
|
||||
universalProperty: undefined,
|
||||
isOverridable: true,
|
||||
translatable: true,
|
||||
},
|
||||
labelSingular: {
|
||||
toCompare: true,
|
||||
toStringify: false,
|
||||
universalProperty: undefined,
|
||||
isOverridable: true,
|
||||
translatable: true,
|
||||
},
|
||||
namePlural: {
|
||||
toCompare: true,
|
||||
@@ -207,7 +229,7 @@ export const ALL_ENTITY_PROPERTIES_CONFIGURATION_BY_METADATA_NAME = {
|
||||
// @ts-expect-error remove once https://github.com/twentyhq/core-team-issues/issues/2172 has been resolved
|
||||
universalProperty: 'labelIdentifierFieldMetadataUniversalIdentifier',
|
||||
},
|
||||
standardOverrides: {
|
||||
overrides: {
|
||||
toCompare: true,
|
||||
toStringify: true,
|
||||
universalProperty: undefined,
|
||||
@@ -1830,3 +1852,12 @@ export type MetadataEntityOverridablePropertyName<T extends AllMetadataName> =
|
||||
FilterOverridableKeys<
|
||||
(typeof ALL_ENTITY_PROPERTIES_CONFIGURATION_BY_METADATA_NAME)[T]
|
||||
>;
|
||||
|
||||
type FilterTranslatableKeys<TConfig> = {
|
||||
[P in keyof TConfig]: TConfig[P] extends { translatable: true } ? P : never;
|
||||
}[keyof TConfig];
|
||||
|
||||
export type MetadataEntityTranslatablePropertyName<T extends AllMetadataName> =
|
||||
FilterTranslatableKeys<
|
||||
(typeof ALL_ENTITY_PROPERTIES_CONFIGURATION_BY_METADATA_NAME)[T]
|
||||
>;
|
||||
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
import {
|
||||
ALL_METADATA_NAME,
|
||||
type AllMetadataName,
|
||||
} from 'twenty-shared/metadata';
|
||||
|
||||
import {
|
||||
ALL_ENTITY_PROPERTIES_CONFIGURATION_BY_METADATA_NAME,
|
||||
type MetadataEntityTranslatablePropertyName,
|
||||
} from 'src/engine/metadata-modules/flat-entity/constant/all-entity-properties-configuration-by-metadata-name.constant';
|
||||
|
||||
const computeTranslatableProperties = <T extends AllMetadataName>(
|
||||
metadataName: T,
|
||||
): MetadataEntityTranslatablePropertyName<T>[] => {
|
||||
const config =
|
||||
ALL_ENTITY_PROPERTIES_CONFIGURATION_BY_METADATA_NAME[metadataName];
|
||||
|
||||
return (Object.entries(config) as [string, { translatable?: boolean }][])
|
||||
.filter(([_, conf]) => conf.translatable === true)
|
||||
.map(([property]) => property as MetadataEntityTranslatablePropertyName<T>);
|
||||
};
|
||||
|
||||
export const ALL_TRANSLATABLE_PROPERTIES_BY_METADATA_NAME = Object.values(
|
||||
ALL_METADATA_NAME,
|
||||
).reduce(
|
||||
(acc, metadataName) => ({
|
||||
...acc,
|
||||
[metadataName]: computeTranslatableProperties(metadataName),
|
||||
}),
|
||||
{} as {
|
||||
[P in AllMetadataName]: MetadataEntityTranslatablePropertyName<P>[];
|
||||
},
|
||||
);
|
||||
+2
-2
@@ -17,7 +17,7 @@ type Assertions = [
|
||||
| 'description'
|
||||
| 'isActive'
|
||||
| 'defaultValue'
|
||||
| 'standardOverrides'
|
||||
| 'overrides'
|
||||
| 'options'
|
||||
| 'settings'
|
||||
| 'isUnique'
|
||||
@@ -43,7 +43,7 @@ type Assertions = [
|
||||
| 'color'
|
||||
| 'description'
|
||||
| 'isActive'
|
||||
| 'standardOverrides'
|
||||
| 'overrides'
|
||||
| 'isLabelSyncedWithName'
|
||||
| 'nameSingular'
|
||||
| 'namePlural'
|
||||
|
||||
Reference in New Issue
Block a user