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:
Weiko
2026-06-17 15:56:27 +02:00
committed by GitHub
parent 57d15fa73a
commit 3ee93b5ec9
102 changed files with 4572 additions and 530 deletions
@@ -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,
@@ -33,6 +33,7 @@ export const getPageLayoutTabFlatEntitySeeds = ({
widgetIds: [],
widgetUniversalIdentifiers: [],
isActive: true,
isSystemSideEffect: false,
icon: null,
layoutMode: PageLayoutTabLayoutMode.GRID,
overrides: null,
@@ -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,
@@ -71,6 +71,7 @@ export const prefillWorkflowCommandMenuItems = async ({
pageLayoutId: null,
pageLayoutUniversalIdentifier: null,
isActive: true,
isSystemSideEffect: false,
overrides: null,
universalOverrides: null,
createdAt: now,
@@ -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,
@@ -77,6 +77,7 @@ export const createStandardCommandMenuItemFlatMetadata = ({
pageLayoutId: null,
pageLayoutUniversalIdentifier: null,
isActive: true,
isSystemSideEffect: false,
overrides: null,
universalOverrides: null,
createdAt: now,
@@ -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,
@@ -101,6 +101,7 @@ export const createStandardRelationFieldFlatMetadata = <
icon,
isActive: true,
isSystem: false,
isSystemSideEffect: false,
isNullable,
isUnique: false,
isUIEditable,
@@ -97,6 +97,7 @@ export const createStandardIndexFlatMetadata = <
indexWhereClause,
isCustom: false,
isUnique,
isSystemSideEffect: true,
objectMetadataUniversalIdentifier: flatObjectMetadata.universalIdentifier,
universalIdentifier: indexDefinition.universalIdentifier,
updatedAt: now,
@@ -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,
@@ -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,
@@ -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,
@@ -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,
});
}
@@ -121,6 +121,7 @@ export const createStandardViewFieldFlatMetadata = <
size,
aggregateOperation,
isActive: true,
isSystemSideEffect: false,
overrides: null,
universalOverrides: null,
createdAt: now,
@@ -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: [],
@@ -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": [],
@@ -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,
@@ -58,6 +58,7 @@ describe('flatEntityToScalarFlatEntity', () => {
"isLabelSyncedWithName": false,
"isNullable": true,
"isSystem": false,
"isSystemSideEffect": false,
"isUIEditable": true,
"isUnique": false,
"label": "Test Label",