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
@@ -1,3 +1,5 @@
import { ViewKey } from 'twenty-shared/types';
import { type FlatPageLayoutWidgetMaps } from 'src/engine/metadata-modules/flat-page-layout-widget/types/flat-page-layout-widget-maps.type';
import { type FlatViewFieldGroupMaps } from 'src/engine/metadata-modules/flat-view-field-group/types/flat-view-field-group-maps.type';
import { DEFAULT_VIEW_FIELD_SIZE } from 'src/engine/metadata-modules/flat-view-field/constants/default-view-field-size.constant';
@@ -21,13 +23,30 @@ const buildEmptyFlatEntityMaps = () => ({
});
const buildFlatViewMaps = (
entries: { id: string; universalIdentifier: string }[] = [],
entries: {
id: string;
universalIdentifier: string;
key?: ViewKey | null;
isActive?: boolean;
isSystemSideEffect?: boolean;
objectMetadataUniversalIdentifier?: string | null;
deletedAt?: string | null;
}[] = [],
): FlatViewMaps =>
({
byUniversalIdentifier: Object.fromEntries(
entries.map((entry) => [
entry.universalIdentifier,
{ universalIdentifier: entry.universalIdentifier, id: entry.id },
{
universalIdentifier: entry.universalIdentifier,
id: entry.id,
key: entry.key ?? null,
isActive: entry.isActive ?? true,
isSystemSideEffect: entry.isSystemSideEffect ?? false,
objectMetadataUniversalIdentifier:
entry.objectMetadataUniversalIdentifier ?? null,
deletedAt: entry.deletedAt ?? null,
},
]),
),
universalIdentifierById: Object.fromEntries(
@@ -713,6 +732,196 @@ describe('computeFlatViewFieldsFromFieldsWidgets', () => {
});
});
describe('isSystemSideEffect inheritance from parent view', () => {
it('should flag the created view field when the parent view is a system side effect', () => {
const result = computeFlatViewFieldsFromFieldsWidgets({
fieldsToCreate: [
{
objectMetadataUniversalIdentifier:
OBJECT_METADATA_UNIVERSAL_IDENTIFIER,
fieldMetadataUniversalIdentifier: 'field-uid-1',
},
],
flatPageLayoutWidgetMaps: buildFlatPageLayoutWidgetMaps([
buildFieldsWidget(),
]),
flatViewFieldMaps: buildFlatViewFieldMaps(),
flatViewMaps: buildFlatViewMaps([
{
id: VIEW_ID,
universalIdentifier: VIEW_UNIVERSAL_IDENTIFIER,
isSystemSideEffect: true,
},
]),
flatViewFieldGroupMaps: buildFlatViewFieldGroupMaps(),
applicationUniversalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER,
});
expect(result).toHaveLength(1);
expect(result[0].isSystemSideEffect).toBe(true);
});
it('should not flag the created view field when the parent view is not a system side effect', () => {
const result = computeFlatViewFieldsFromFieldsWidgets({
fieldsToCreate: [
{
objectMetadataUniversalIdentifier:
OBJECT_METADATA_UNIVERSAL_IDENTIFIER,
fieldMetadataUniversalIdentifier: 'field-uid-1',
},
],
flatPageLayoutWidgetMaps: buildFlatPageLayoutWidgetMaps([
buildFieldsWidget(),
]),
flatViewFieldMaps: buildFlatViewFieldMaps(),
flatViewMaps: buildFlatViewMaps([
{
id: VIEW_ID,
universalIdentifier: VIEW_UNIVERSAL_IDENTIFIER,
isSystemSideEffect: false,
},
]),
flatViewFieldGroupMaps: buildFlatViewFieldGroupMaps(),
applicationUniversalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER,
});
expect(result).toHaveLength(1);
expect(result[0].isSystemSideEffect).toBe(false);
});
});
describe('INDEX view propagation', () => {
it('should add a hidden, flagged view field to the object INDEX view even without a fields widget', () => {
const result = computeFlatViewFieldsFromFieldsWidgets({
fieldsToCreate: [
{
objectMetadataUniversalIdentifier:
OBJECT_METADATA_UNIVERSAL_IDENTIFIER,
fieldMetadataUniversalIdentifier: 'field-uid-1',
},
],
flatPageLayoutWidgetMaps:
buildEmptyFlatEntityMaps() as unknown as FlatPageLayoutWidgetMaps,
flatViewFieldMaps: buildFlatViewFieldMaps(),
flatViewMaps: buildFlatViewMaps([
{
id: 'index-view-db-id',
universalIdentifier: 'index-view-uid',
key: ViewKey.INDEX,
isSystemSideEffect: true,
objectMetadataUniversalIdentifier:
OBJECT_METADATA_UNIVERSAL_IDENTIFIER,
},
]),
flatViewFieldGroupMaps: buildFlatViewFieldGroupMaps(),
applicationUniversalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER,
});
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
viewUniversalIdentifier: 'index-view-uid',
fieldMetadataUniversalIdentifier: 'field-uid-1',
isVisible: false,
isSystemSideEffect: true,
});
});
it('should not target a standalone non-INDEX view without a widget', () => {
const result = computeFlatViewFieldsFromFieldsWidgets({
fieldsToCreate: [
{
objectMetadataUniversalIdentifier:
OBJECT_METADATA_UNIVERSAL_IDENTIFIER,
fieldMetadataUniversalIdentifier: 'field-uid-1',
},
],
flatPageLayoutWidgetMaps:
buildEmptyFlatEntityMaps() as unknown as FlatPageLayoutWidgetMaps,
flatViewFieldMaps: buildFlatViewFieldMaps(),
flatViewMaps: buildFlatViewMaps([
{
id: 'plain-view-db-id',
universalIdentifier: 'plain-view-uid',
key: null,
objectMetadataUniversalIdentifier:
OBJECT_METADATA_UNIVERSAL_IDENTIFIER,
},
]),
flatViewFieldGroupMaps: buildFlatViewFieldGroupMaps(),
applicationUniversalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER,
});
expect(result).toEqual([]);
});
it('should skip an inactive or soft-deleted INDEX view', () => {
const result = computeFlatViewFieldsFromFieldsWidgets({
fieldsToCreate: [
{
objectMetadataUniversalIdentifier:
OBJECT_METADATA_UNIVERSAL_IDENTIFIER,
fieldMetadataUniversalIdentifier: 'field-uid-1',
},
],
flatPageLayoutWidgetMaps:
buildEmptyFlatEntityMaps() as unknown as FlatPageLayoutWidgetMaps,
flatViewFieldMaps: buildFlatViewFieldMaps(),
flatViewMaps: buildFlatViewMaps([
{
id: 'inactive-index-view-db-id',
universalIdentifier: 'inactive-index-view-uid',
key: ViewKey.INDEX,
isActive: false,
objectMetadataUniversalIdentifier:
OBJECT_METADATA_UNIVERSAL_IDENTIFIER,
},
{
id: 'deleted-index-view-db-id',
universalIdentifier: 'deleted-index-view-uid',
key: ViewKey.INDEX,
deletedAt: '2024-01-01T00:00:00.000Z',
objectMetadataUniversalIdentifier:
OBJECT_METADATA_UNIVERSAL_IDENTIFIER,
},
]),
flatViewFieldGroupMaps: buildFlatViewFieldGroupMaps(),
applicationUniversalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER,
});
expect(result).toEqual([]);
});
it('should not duplicate a field when a fields widget and the INDEX view share the same view', () => {
const result = computeFlatViewFieldsFromFieldsWidgets({
fieldsToCreate: [
{
objectMetadataUniversalIdentifier:
OBJECT_METADATA_UNIVERSAL_IDENTIFIER,
fieldMetadataUniversalIdentifier: 'field-uid-1',
},
],
flatPageLayoutWidgetMaps: buildFlatPageLayoutWidgetMaps([
buildFieldsWidget({ viewId: 'index-view-db-id', isVisible: true }),
]),
flatViewFieldMaps: buildFlatViewFieldMaps(),
flatViewMaps: buildFlatViewMaps([
{
id: 'index-view-db-id',
universalIdentifier: 'index-view-uid',
key: ViewKey.INDEX,
objectMetadataUniversalIdentifier:
OBJECT_METADATA_UNIVERSAL_IDENTIFIER,
},
]),
flatViewFieldGroupMaps: buildFlatViewFieldGroupMaps(),
applicationUniversalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER,
});
expect(result).toHaveLength(1);
expect(result[0].isVisible).toBe(true);
});
});
describe('unique universal identifiers', () => {
it('should generate unique universalIdentifier for each created view field', () => {
const result = computeFlatViewFieldsFromFieldsWidgets({
@@ -1,8 +1,8 @@
import { ViewKey } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { v4 } from 'uuid';
import { type FlatPageLayoutWidgetMaps } from 'src/engine/metadata-modules/flat-page-layout-widget/types/flat-page-layout-widget-maps.type';
import { type FlatPageLayoutWidget } from 'src/engine/metadata-modules/flat-page-layout-widget/types/flat-page-layout-widget.type';
import { type FlatViewFieldGroupMaps } from 'src/engine/metadata-modules/flat-view-field-group/types/flat-view-field-group-maps.type';
import { DEFAULT_VIEW_FIELD_SIZE } from 'src/engine/metadata-modules/flat-view-field/constants/default-view-field-size.constant';
import { type FlatViewFieldMaps } from 'src/engine/metadata-modules/flat-view-field/types/flat-view-field-maps.type';
@@ -19,6 +19,11 @@ type FieldToCreateInfo = {
fieldMetadataUniversalIdentifier: string;
};
type FieldViewTarget = {
viewId: string;
isVisible: boolean;
};
const isFieldsWidgetConfiguration = (
configuration: AllPageLayoutWidgetConfiguration,
): configuration is FieldsConfigurationDTO => {
@@ -28,25 +33,65 @@ const isFieldsWidgetConfiguration = (
);
};
const getMatchingFieldsWidgets = ({
const getFieldViewTargets = ({
objectMetadataUniversalIdentifier,
flatPageLayoutWidgetMaps,
flatViewMaps,
}: {
objectMetadataUniversalIdentifier: string;
flatPageLayoutWidgetMaps: FlatPageLayoutWidgetMaps;
}): FlatPageLayoutWidget[] =>
Object.values(flatPageLayoutWidgetMaps.byUniversalIdentifier)
.filter(isDefined)
.filter(
(widget) =>
widget.isActive &&
widget.type === WidgetType.FIELDS &&
widget.objectMetadataUniversalIdentifier ===
objectMetadataUniversalIdentifier &&
isFieldsWidgetConfiguration(widget.configuration) &&
isDefined(widget.configuration.viewId) &&
isDefined(widget.configuration.newFieldDefaultVisibility),
);
flatViewMaps: FlatViewMaps;
}): FieldViewTarget[] => {
const targets: FieldViewTarget[] = [];
const seenViewIds = new Set<string>();
for (const widget of Object.values(
flatPageLayoutWidgetMaps.byUniversalIdentifier,
).filter(isDefined)) {
if (
!widget.isActive ||
widget.type !== WidgetType.FIELDS ||
widget.objectMetadataUniversalIdentifier !==
objectMetadataUniversalIdentifier ||
!isFieldsWidgetConfiguration(widget.configuration)
) {
continue;
}
const { viewId, newFieldDefaultVisibility } = widget.configuration;
if (
!isDefined(viewId) ||
!isDefined(newFieldDefaultVisibility) ||
seenViewIds.has(viewId)
) {
continue;
}
seenViewIds.add(viewId);
targets.push({ viewId, isVisible: newFieldDefaultVisibility });
}
for (const view of Object.values(flatViewMaps.byUniversalIdentifier).filter(
isDefined,
)) {
if (
view.key !== ViewKey.INDEX ||
!view.isActive ||
isDefined(view.deletedAt) ||
view.objectMetadataUniversalIdentifier !==
objectMetadataUniversalIdentifier ||
seenViewIds.has(view.id)
) {
continue;
}
seenViewIds.add(view.id);
targets.push({ viewId: view.id, isVisible: false });
}
return targets;
};
const findLastViewFieldGroupId = ({
viewId,
@@ -130,11 +175,13 @@ export const computeFlatViewFieldsFromFieldsWidgets = ({
];
const nextPositionByKey = new Map<string, number>();
const queuedViewFieldKeys = new Set<string>();
for (const objectMetadataUniversalIdentifier of objectMetadataUniversalIdentifiers) {
const matchingWidgets = getMatchingFieldsWidgets({
const targets = getFieldViewTargets({
objectMetadataUniversalIdentifier,
flatPageLayoutWidgetMaps,
flatViewMaps,
});
const fieldsForObject = fieldsToCreate.filter(
@@ -143,16 +190,7 @@ export const computeFlatViewFieldsFromFieldsWidgets = ({
objectMetadataUniversalIdentifier,
);
for (const widget of matchingWidgets) {
if (!isFieldsWidgetConfiguration(widget.configuration)) {
continue;
}
const configuration = widget.configuration;
const viewId = configuration.viewId!;
const isVisible = configuration.newFieldDefaultVisibility!;
for (const { viewId, isVisible } of targets) {
const viewUniversalIdentifier =
flatViewMaps.universalIdentifierById[viewId] ?? null;
@@ -160,6 +198,10 @@ export const computeFlatViewFieldsFromFieldsWidgets = ({
continue;
}
const isSystemSideEffect =
flatViewMaps.byUniversalIdentifier[viewUniversalIdentifier]
?.isSystemSideEffect ?? false;
const viewFieldGroupId = findLastViewFieldGroupId({
viewId,
flatViewFieldGroupMaps,
@@ -184,6 +226,14 @@ export const computeFlatViewFieldsFromFieldsWidgets = ({
}
for (const field of fieldsForObject) {
const dedupKey = `${viewId}:${field.fieldMetadataUniversalIdentifier}`;
if (queuedViewFieldKeys.has(dedupKey)) {
continue;
}
queuedViewFieldKeys.add(dedupKey);
const position = nextPositionByKey.get(positionKey)!;
nextPositionByKey.set(positionKey, position + 1);
@@ -200,6 +250,7 @@ export const computeFlatViewFieldsFromFieldsWidgets = ({
position,
aggregateOperation: null,
isActive: true,
isSystemSideEffect,
universalOverrides: null,
createdAt: now,
updatedAt: now,
@@ -67,6 +67,7 @@ export const fromCreateViewFieldInputToFlatViewFieldToCreate = ({
position: createViewFieldInput.position ?? 0,
aggregateOperation: createViewFieldInput.aggregateOperation ?? null,
isActive: true,
isSystemSideEffect: false,
universalOverrides: null,
viewFieldGroupUniversalIdentifier,
applicationUniversalIdentifier: flatApplication.universalIdentifier,
@@ -64,6 +64,7 @@ export const fromUpdateViewFieldInputToFlatViewFieldToUpdateOrThrow = ({
entityApplicationUniversalIdentifier:
existingFlatViewFieldToUpdate.applicationUniversalIdentifier,
workspaceCustomApplicationUniversalIdentifier,
isSystemSideEffect: existingFlatViewFieldToUpdate.isSystemSideEffect,
});
const { overrides, updatedEditableProperties } =