feat(server): add isSystemSideEffect & merge createOneObject/createOneField side-effect migrations (#21673)
## Context When an object is created via the metadata API, `createOneObject` creates its side-effect entities (INDEX view + viewFields, indexes, navigation menu item, "go to" command menu item, record-page fields view, page layout/tabs/widgets) across **three separate `validateBuildAndRunWorkspaceMigration` calls**, purely because the protection behavior (mutations → overrides, delete → deactivate, reset → reactivate) was keyed on *"owned by the standard app"*, forcing the side effects into batches with different application owners. This misrepresents ownership and breaks atomicity. This PR separates two orthogonal concepts: - **Ownership** (`applicationId`), the true owner: the caller's application (the workspace custom app today, 3rd-party apps later). - **Protection** (`isSystemSideEffect`), the row was generated by the system, so user mutations route to overrides, deletion becomes deactivation, and reset restores defaults. Once side effects are re-owned to the caller, the old `applicationId === standardApp` check can no longer tell an original side-effect row from a user-added one so a dedicated `isSystemSideEffect` flag carries the protection instead. This is **PR 1 of 2** (forward-only). It makes newly created objects and fields correct; existing workspaces are handled by a follow-up backfill (see *Out of scope*). ## What this PR does - **`isSystemSideEffect` column** on the 8 affected entities (`view`, `viewField`, `indexMetadata`, `commandMenuItem`, `pageLayout`, `pageLayoutTab`, `pageLayoutWidget`, `fieldMetadata`), with `@WasIntroducedInUpgrade` + an entry in the flat-entity property configuration (`toCompare: true`, read-only). - **Single atomic migration in `createOneObject`**: the three `validateBuildAndRunWorkspaceMigration` calls are merged into one, owned by the caller (`resolvedOwnerFlatApplication`) and the record-page view/fields, page layout, and navigation command item are re-owned to the caller and flagged `isSystemSideEffect: true`. `buildNavigationFlatCommandMenuItem` is parameterized with `applicationUniversalIdentifier` (no longer hardcoded to the standard app). - **Field-creation side effects** (`createManyFields`/`createOneField` already run as a single caller-owned migration, so no re-ownership/merge was needed): the auto-created viewField is flagged `isSystemSideEffect: true`, and a new field now also propagates to the object's **INDEX/table view** (added there as a **hidden** column, `isVisible: false`) in addition to the record-page FIELDS widget. The INDEX view is targeted directly by `key = INDEX` (it is not a page-layout widget), de-duplicated per `(viewId, fieldMetadataUniversalIdentifier)` to respect the per-view unique index. The unique-field index is likewise flagged the inverse relation field stays unflagged (`isSystem: false`). - **Protection predicate** extended: `isCallerOverridingEntity` and the removal/reset split strategies now treat `isSystemSideEffect` rows as protected even when caller-owned (route to overrides / deactivate / reset) and the page-layout-reset guards allow resetting flagged entities. - **Standard compute maps** set the flag consistently so a re-sync produces no diff (standard-object side effects stay `false`; per-object nav command items and custom-object base fields are `true`). - **Read-only GraphQL exposure** of `isSystemSideEffect` on the view / view-field / page-layout / tab / widget / command-menu-item DTOs (not exposed on create/update inputs). => Todo: needs to take this new flag into account. This is fine for now because isSystem remains on object/field. - **Fast instance command** (`2-14`) adding the 8 columns (`NOT NULL DEFAULT false`). ## Scope decisions - **`pageLayout` is not an `OverridableEntity`**, its own row has nothing user-overridable (all customization lives on tabs/widgets). It's dual-purpose (`RECORD_PAGE` side-effect vs. user `DASHBOARD`), so it gets `isSystemSideEffect` for protection only, no `overrides` jsonb. - **`navigationMenuItem` is out of scope.**: Those are side effects only for the metadata API and not marked as "system" (they can be deleted/updated etc...) - **`viewFieldGroup` is not a side effect**, it's only created via the explicit view-field-group API, never by object/field creation, so it gets no flag. ## Out of scope (follow-ups) **PR 2** — slow per-workspace backfill (re-own + flag existing side effects, recreate missing ones) and deterministic v5 identifiers for base fields / pageLayout / tab. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/21673?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. -->
This commit is contained in:
+4
@@ -23,6 +23,7 @@ export const getPageLayoutFlatEntitySeeds = ({
|
||||
applicationId: flatApplication.id,
|
||||
applicationUniversalIdentifier: flatApplication.universalIdentifier,
|
||||
workspaceId,
|
||||
isSystemSideEffect: false,
|
||||
name: 'Sales Dashboard Layout',
|
||||
type: PageLayoutType.DASHBOARD,
|
||||
objectMetadataId: null,
|
||||
@@ -44,6 +45,7 @@ export const getPageLayoutFlatEntitySeeds = ({
|
||||
applicationId: flatApplication.id,
|
||||
applicationUniversalIdentifier: flatApplication.universalIdentifier,
|
||||
workspaceId,
|
||||
isSystemSideEffect: false,
|
||||
name: 'Customer Dashboard Layout',
|
||||
type: PageLayoutType.DASHBOARD,
|
||||
objectMetadataId: null,
|
||||
@@ -65,6 +67,7 @@ export const getPageLayoutFlatEntitySeeds = ({
|
||||
applicationId: flatApplication.id,
|
||||
applicationUniversalIdentifier: flatApplication.universalIdentifier,
|
||||
workspaceId,
|
||||
isSystemSideEffect: false,
|
||||
name: 'Team Dashboard Layout',
|
||||
type: PageLayoutType.DASHBOARD,
|
||||
objectMetadataId: null,
|
||||
@@ -89,6 +92,7 @@ export const getPageLayoutFlatEntitySeeds = ({
|
||||
applicationId: flatApplication.id,
|
||||
applicationUniversalIdentifier: flatApplication.universalIdentifier,
|
||||
workspaceId,
|
||||
isSystemSideEffect: false,
|
||||
name: 'Documentation',
|
||||
type: PageLayoutType.STANDALONE_PAGE,
|
||||
objectMetadataId: null,
|
||||
|
||||
+1
@@ -33,6 +33,7 @@ export const getPageLayoutTabFlatEntitySeeds = ({
|
||||
widgetIds: [],
|
||||
widgetUniversalIdentifiers: [],
|
||||
isActive: true,
|
||||
isSystemSideEffect: false,
|
||||
icon: null,
|
||||
layoutMode: PageLayoutTabLayoutMode.GRID,
|
||||
overrides: null,
|
||||
|
||||
+1
@@ -92,6 +92,7 @@ export const prefillFrontComponentCommandMenuItems = async ({
|
||||
pageLayoutId: definition.pageLayoutId ?? null,
|
||||
pageLayoutUniversalIdentifier: definition.pageLayoutId ?? null,
|
||||
isActive: true,
|
||||
isSystemSideEffect: false,
|
||||
overrides: null,
|
||||
universalOverrides: null,
|
||||
createdAt: now,
|
||||
|
||||
+1
@@ -71,6 +71,7 @@ export const prefillWorkflowCommandMenuItems = async ({
|
||||
pageLayoutId: null,
|
||||
pageLayoutUniversalIdentifier: null,
|
||||
isActive: true,
|
||||
isSystemSideEffect: false,
|
||||
overrides: null,
|
||||
universalOverrides: null,
|
||||
createdAt: now,
|
||||
|
||||
+3
@@ -1,3 +1,4 @@
|
||||
import { TWENTY_STANDARD_APPLICATION_UNIVERSAL_IDENTIFIER } from 'twenty-shared/application';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { v4 } from 'uuid';
|
||||
|
||||
@@ -72,6 +73,8 @@ export const buildStandardFlatCommandMenuItemMaps = ({
|
||||
objectMetadata: flatObject,
|
||||
commandMenuItemId: v4(),
|
||||
applicationId: twentyStandardApplicationId,
|
||||
applicationUniversalIdentifier:
|
||||
TWENTY_STANDARD_APPLICATION_UNIVERSAL_IDENTIFIER,
|
||||
workspaceId,
|
||||
position,
|
||||
now,
|
||||
|
||||
+1
@@ -77,6 +77,7 @@ export const createStandardCommandMenuItemFlatMetadata = ({
|
||||
pageLayoutId: null,
|
||||
pageLayoutUniversalIdentifier: null,
|
||||
isActive: true,
|
||||
isSystemSideEffect: false,
|
||||
overrides: null,
|
||||
universalOverrides: null,
|
||||
createdAt: now,
|
||||
|
||||
+2
@@ -9,6 +9,7 @@ import { STANDARD_OBJECTS } from 'twenty-shared/metadata';
|
||||
import { TWENTY_STANDARD_APPLICATION_UNIVERSAL_IDENTIFIER } from 'twenty-shared/application';
|
||||
|
||||
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
|
||||
import { PARTIAL_SYSTEM_FLAT_FIELD_METADATAS } from 'src/engine/metadata-modules/object-metadata/constants/partial-system-flat-field-metadatas.constant';
|
||||
import { type AllStandardObjectFieldName } from 'src/engine/workspace-manager/twenty-standard-application/types/all-standard-object-field-name.type';
|
||||
import { type AllStandardObjectName } from 'src/engine/workspace-manager/twenty-standard-application/types/all-standard-object-name.type';
|
||||
import { type StandardBuilderArgs } from 'src/engine/workspace-manager/twenty-standard-application/types/metadata-standard-buillder-args.type';
|
||||
@@ -82,6 +83,7 @@ export const createStandardFieldFlatMetadata = <
|
||||
icon,
|
||||
isActive: true,
|
||||
isSystem,
|
||||
isSystemSideEffect: name in PARTIAL_SYSTEM_FLAT_FIELD_METADATAS,
|
||||
isNullable,
|
||||
isUnique,
|
||||
isUIEditable,
|
||||
|
||||
+1
@@ -101,6 +101,7 @@ export const createStandardRelationFieldFlatMetadata = <
|
||||
icon,
|
||||
isActive: true,
|
||||
isSystem: false,
|
||||
isSystemSideEffect: false,
|
||||
isNullable,
|
||||
isUnique: false,
|
||||
isUIEditable,
|
||||
|
||||
+1
@@ -97,6 +97,7 @@ export const createStandardIndexFlatMetadata = <
|
||||
indexWhereClause,
|
||||
isCustom: false,
|
||||
isUnique,
|
||||
isSystemSideEffect: true,
|
||||
objectMetadataUniversalIdentifier: flatObjectMetadata.universalIdentifier,
|
||||
universalIdentifier: indexDefinition.universalIdentifier,
|
||||
updatedAt: now,
|
||||
|
||||
+3
@@ -2,6 +2,7 @@ import { type PageLayoutTabLayoutMode } from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { type FlatPageLayoutTab } from 'src/engine/metadata-modules/flat-page-layout-tab/types/flat-page-layout-tab.type';
|
||||
import { PageLayoutType } from 'src/engine/metadata-modules/page-layout/enums/page-layout-type.enum';
|
||||
import { STANDARD_PAGE_LAYOUTS } from 'src/engine/workspace-manager/twenty-standard-application/constants/standard-page-layout.constant';
|
||||
import { TWENTY_STANDARD_APPLICATION } from 'src/engine/workspace-manager/twenty-standard-application/constants/twenty-standard-applications';
|
||||
import { type StandardPageLayoutMetadataRelatedEntityIds } from 'src/engine/workspace-manager/twenty-standard-application/utils/get-standard-page-layout-metadata-related-entity-ids.util';
|
||||
@@ -36,6 +37,7 @@ export const createStandardPageLayoutTabFlatMetadata = ({
|
||||
layoutName as keyof typeof STANDARD_PAGE_LAYOUTS
|
||||
] as {
|
||||
universalIdentifier: string;
|
||||
type: PageLayoutType;
|
||||
tabs: Record<
|
||||
string,
|
||||
StandardPageLayoutTabConfig & {
|
||||
@@ -69,6 +71,7 @@ export const createStandardPageLayoutTabFlatMetadata = ({
|
||||
widgetIds,
|
||||
widgetUniversalIdentifiers,
|
||||
isActive: true,
|
||||
isSystemSideEffect: layout.type === PageLayoutType.RECORD_PAGE,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
deletedAt: null,
|
||||
|
||||
+3
@@ -7,6 +7,7 @@ import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { type MetadataUniversalFlatEntity } from 'src/engine/metadata-modules/flat-entity/types/metadata-universal-flat-entity.type';
|
||||
import { type FlatPageLayoutWidget } from 'src/engine/metadata-modules/flat-page-layout-widget/types/flat-page-layout-widget.type';
|
||||
import { PageLayoutType } from 'src/engine/metadata-modules/page-layout/enums/page-layout-type.enum';
|
||||
import { type WidgetType } from 'src/engine/metadata-modules/page-layout-widget/enums/widget-type.enum';
|
||||
import { type AllPageLayoutWidgetConfiguration } from 'src/engine/metadata-modules/page-layout-widget/types/all-page-layout-widget-configuration.type';
|
||||
import { STANDARD_PAGE_LAYOUTS } from 'src/engine/workspace-manager/twenty-standard-application/constants/standard-page-layout.constant';
|
||||
@@ -69,6 +70,7 @@ export const createStandardPageLayoutWidgetFlatMetadata = ({
|
||||
const layout = STANDARD_PAGE_LAYOUTS[
|
||||
layoutName as keyof typeof STANDARD_PAGE_LAYOUTS
|
||||
] as {
|
||||
type: PageLayoutType;
|
||||
tabs: Record<
|
||||
string,
|
||||
StandardPageLayoutTabConfig & {
|
||||
@@ -107,6 +109,7 @@ export const createStandardPageLayoutWidgetFlatMetadata = ({
|
||||
objectMetadataId,
|
||||
objectMetadataUniversalIdentifier,
|
||||
isActive: true,
|
||||
isSystemSideEffect: layout.type === PageLayoutType.RECORD_PAGE,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
deletedAt: null,
|
||||
|
||||
+2
-1
@@ -3,7 +3,7 @@ import { STANDARD_OBJECTS } from 'twenty-shared/metadata';
|
||||
|
||||
import { type FlatPageLayout } from 'src/engine/metadata-modules/flat-page-layout/types/flat-page-layout.type';
|
||||
import { TWENTY_STANDARD_APPLICATION } from 'src/engine/workspace-manager/twenty-standard-application/constants/twenty-standard-applications';
|
||||
import { type PageLayoutType } from 'src/engine/metadata-modules/page-layout/enums/page-layout-type.enum';
|
||||
import { PageLayoutType } from 'src/engine/metadata-modules/page-layout/enums/page-layout-type.enum';
|
||||
import { STANDARD_PAGE_LAYOUTS } from 'src/engine/workspace-manager/twenty-standard-application/constants/standard-page-layout.constant';
|
||||
import { type StandardObjectMetadataRelatedEntityIds } from 'src/engine/workspace-manager/twenty-standard-application/utils/get-standard-object-metadata-related-entity-ids.util';
|
||||
import { type StandardPageLayoutMetadataRelatedEntityIds } from 'src/engine/workspace-manager/twenty-standard-application/utils/get-standard-page-layout-metadata-related-entity-ids.util';
|
||||
@@ -119,6 +119,7 @@ export const createStandardPageLayoutFlatMetadata = ({
|
||||
type,
|
||||
objectMetadataId,
|
||||
objectMetadataUniversalIdentifier: objectUniversalIdentifier,
|
||||
isSystemSideEffect: type === PageLayoutType.RECORD_PAGE,
|
||||
tabIds: [],
|
||||
tabUniversalIdentifiers: [],
|
||||
createdAt: now,
|
||||
|
||||
+11
-1
@@ -75,6 +75,8 @@ export type BuildStandardFlatViewFieldMetadataMapsArgs = Omit<
|
||||
export const buildStandardFlatViewFieldMetadataMaps = (
|
||||
args: BuildStandardFlatViewFieldMetadataMapsArgs,
|
||||
): FlatEntityMaps<FlatViewField> => {
|
||||
const { flatViewMaps } = args.dependencyFlatEntityMaps;
|
||||
|
||||
const allViewFieldMetadatas: FlatViewField[] = (
|
||||
Object.keys(
|
||||
STANDARD_FLAT_VIEW_FIELD_METADATA_BUILDERS_BY_OBJECT_NAME,
|
||||
@@ -94,8 +96,16 @@ export const buildStandardFlatViewFieldMetadataMaps = (
|
||||
let flatViewFieldMaps = createEmptyFlatEntityMaps();
|
||||
|
||||
for (const viewFieldMetadata of allViewFieldMetadatas) {
|
||||
const parentView =
|
||||
flatViewMaps.byUniversalIdentifier[
|
||||
viewFieldMetadata.viewUniversalIdentifier
|
||||
];
|
||||
|
||||
flatViewFieldMaps = addFlatEntityToFlatEntityMapsOrThrow({
|
||||
flatEntity: viewFieldMetadata,
|
||||
flatEntity: {
|
||||
...viewFieldMetadata,
|
||||
isSystemSideEffect: parentView?.isSystemSideEffect ?? false,
|
||||
},
|
||||
flatEntityMaps: flatViewFieldMaps,
|
||||
});
|
||||
}
|
||||
|
||||
+1
@@ -121,6 +121,7 @@ export const createStandardViewFieldFlatMetadata = <
|
||||
size,
|
||||
aggregateOperation,
|
||||
isActive: true,
|
||||
isSystemSideEffect: false,
|
||||
overrides: null,
|
||||
universalOverrides: null,
|
||||
createdAt: now,
|
||||
|
||||
+4
-2
@@ -2,8 +2,8 @@ import { isDefined } from 'class-validator';
|
||||
import { STANDARD_OBJECTS } from 'twenty-shared/metadata';
|
||||
import {
|
||||
type AggregateOperations,
|
||||
type ViewType,
|
||||
type ViewKey,
|
||||
ViewType,
|
||||
ViewKey,
|
||||
ViewOpenRecordIn,
|
||||
ViewVisibility,
|
||||
} from 'twenty-shared/types';
|
||||
@@ -143,6 +143,8 @@ export const createStandardViewFlatMetadata = <
|
||||
visibility: ViewVisibility.WORKSPACE,
|
||||
createdByUserWorkspaceId: null,
|
||||
isActive: true,
|
||||
isSystemSideEffect:
|
||||
key === ViewKey.INDEX || type === ViewType.FIELDS_WIDGET,
|
||||
overrides: null,
|
||||
universalOverrides: null,
|
||||
viewFieldIds: [],
|
||||
|
||||
+4
@@ -20,6 +20,7 @@ exports[`flatEntityDeletedCreatedUpdatedMatrixDispatcher It should detect a crea
|
||||
"isLabelSyncedWithName": false,
|
||||
"isNullable": true,
|
||||
"isSystem": false,
|
||||
"isSystemSideEffect": false,
|
||||
"isUIEditable": true,
|
||||
"isUnique": false,
|
||||
"kanbanAggregateOperationViewIds": [],
|
||||
@@ -84,6 +85,7 @@ exports[`flatEntityDeletedCreatedUpdatedMatrixDispatcher It should detect a dele
|
||||
"isLabelSyncedWithName": false,
|
||||
"isNullable": true,
|
||||
"isSystem": false,
|
||||
"isSystemSideEffect": false,
|
||||
"isUIEditable": true,
|
||||
"isUnique": false,
|
||||
"kanbanAggregateOperationViewIds": [],
|
||||
@@ -162,6 +164,7 @@ exports[`flatEntityDeletedCreatedUpdatedMatrixDispatcher It should detect create
|
||||
"isLabelSyncedWithName": false,
|
||||
"isNullable": true,
|
||||
"isSystem": false,
|
||||
"isSystemSideEffect": false,
|
||||
"isUIEditable": true,
|
||||
"isUnique": false,
|
||||
"kanbanAggregateOperationViewIds": [],
|
||||
@@ -212,6 +215,7 @@ exports[`flatEntityDeletedCreatedUpdatedMatrixDispatcher It should detect create
|
||||
"isLabelSyncedWithName": false,
|
||||
"isNullable": true,
|
||||
"isSystem": false,
|
||||
"isSystemSideEffect": false,
|
||||
"isUIEditable": true,
|
||||
"isUnique": false,
|
||||
"kanbanAggregateOperationViewIds": [],
|
||||
|
||||
+1
@@ -82,6 +82,7 @@ export const fromUniversalFlatIndexToFlatIndex = ({
|
||||
name: universalFlatIndexMetadata.name,
|
||||
isCustom: universalFlatIndexMetadata.isCustom,
|
||||
isUnique: universalFlatIndexMetadata.isUnique,
|
||||
isSystemSideEffect: universalFlatIndexMetadata.isSystemSideEffect,
|
||||
indexWhereClause: universalFlatIndexMetadata.indexWhereClause,
|
||||
indexType: universalFlatIndexMetadata.indexType,
|
||||
createdAt: universalFlatIndexMetadata.createdAt,
|
||||
|
||||
+1
@@ -58,6 +58,7 @@ describe('flatEntityToScalarFlatEntity', () => {
|
||||
"isLabelSyncedWithName": false,
|
||||
"isNullable": true,
|
||||
"isSystem": false,
|
||||
"isSystemSideEffect": false,
|
||||
"isUIEditable": true,
|
||||
"isUnique": false,
|
||||
"label": "Test Label",
|
||||
|
||||
Reference in New Issue
Block a user