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 @@ const PARTIAL_ID_FIELD = {
isUnique: true,
isActive: true,
isSystem: true,
isSystemSideEffect: true,
isUIEditable: false,
isLabelSyncedWithName: false,
defaultValue: 'uuid',
@@ -51,6 +52,7 @@ const PARTIAL_CREATED_AT_FIELD = {
isUnique: false,
isActive: true,
isSystem: true,
isSystemSideEffect: true,
isUIEditable: false,
isLabelSyncedWithName: false,
defaultValue: 'now',
@@ -79,6 +81,7 @@ const PARTIAL_UPDATED_AT_FIELD = {
isUnique: false,
isActive: true,
isSystem: true,
isSystemSideEffect: true,
isUIEditable: false,
isLabelSyncedWithName: false,
defaultValue: 'now',
@@ -107,6 +110,7 @@ const PARTIAL_DELETED_AT_FIELD = {
isUnique: false,
isActive: true,
isSystem: true,
isSystemSideEffect: true,
isUIEditable: false,
isLabelSyncedWithName: false,
defaultValue: null,
@@ -135,6 +139,7 @@ const PARTIAL_CREATED_BY_FIELD = {
isUnique: false,
isActive: true,
isSystem: true,
isSystemSideEffect: true,
isUIEditable: false,
isLabelSyncedWithName: false,
defaultValue: { name: "''", source: "'MANUAL'" },
@@ -163,6 +168,7 @@ const PARTIAL_UPDATED_BY_FIELD = {
isUnique: false,
isActive: true,
isSystem: true,
isSystemSideEffect: true,
isUIEditable: false,
isLabelSyncedWithName: false,
defaultValue: { name: "''", source: "'MANUAL'" },
@@ -191,6 +197,7 @@ const PARTIAL_POSITION_FIELD = {
isUnique: false,
isActive: true,
isSystem: true,
isSystemSideEffect: true,
isUIEditable: false,
isLabelSyncedWithName: false,
defaultValue: 0,
@@ -219,6 +226,7 @@ const PARTIAL_SEARCH_VECTOR_FIELD = {
isUnique: false,
isActive: true,
isSystem: true,
isSystemSideEffect: true,
isUIEditable: false,
isLabelSyncedWithName: false,
defaultValue: null,
@@ -145,7 +145,9 @@ export class ObjectMetadataService extends TypeOrmQueryService<ObjectMetadataEnt
existingFlatObjectMetadata,
flatCommandMenuItemMaps: existingFlatCommandMenuItemMaps,
workspaceId,
applicationId: twentyStandardFlatApplication.id,
applicationId: resolvedOwnerFlatApplication.id,
applicationUniversalIdentifier:
resolvedOwnerFlatApplication.universalIdentifier,
});
const validateAndBuildResult =
@@ -465,7 +467,7 @@ export class ObjectMetadataService extends TypeOrmQueryService<ObjectMetadataEnt
workspaceId: string;
ownerFlatApplication?: FlatApplication;
}): Promise<FlatObjectMetadata> {
const { workspaceCustomFlatApplication, twentyStandardFlatApplication } =
const { workspaceCustomFlatApplication } =
await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow(
{
workspaceId,
@@ -497,7 +499,7 @@ export class ObjectMetadataService extends TypeOrmQueryService<ObjectMetadataEnt
});
const flatDefaultViewFieldsToCreate = computeFlatViewFieldsToCreate({
flatApplication: workspaceCustomFlatApplication,
flatApplication: resolvedOwnerFlatApplication,
objectFlatFieldMetadatas: flatFieldMetadataToCreateOnObject,
labelIdentifierFieldMetadataUniversalIdentifier:
flatObjectMetadataToCreate.labelIdentifierFieldMetadataUniversalIdentifier,
@@ -525,11 +527,38 @@ export class ObjectMetadataService extends TypeOrmQueryService<ObjectMetadataEnt
{
objectMetadata: flatObjectMetadataToCreate,
workspaceId,
applicationId: twentyStandardFlatApplication.id,
applicationId: resolvedOwnerFlatApplication.id,
applicationUniversalIdentifier:
resolvedOwnerFlatApplication.universalIdentifier,
flatCommandMenuItemMaps,
},
);
const flatRecordPageFieldsViewToCreate =
this.computeFlatRecordPageFieldsViewToCreate({
objectMetadata: flatObjectMetadataToCreate,
flatApplication: resolvedOwnerFlatApplication,
});
const flatRecordPageFieldsViewFieldsToCreate =
computeFlatViewFieldsToCreate({
flatApplication: resolvedOwnerFlatApplication,
objectFlatFieldMetadatas: flatFieldMetadataToCreateOnObject,
labelIdentifierFieldMetadataUniversalIdentifier:
flatObjectMetadataToCreate.labelIdentifierFieldMetadataUniversalIdentifier,
viewUniversalIdentifier:
flatRecordPageFieldsViewToCreate.universalIdentifier,
excludeLabelIdentifier: true,
});
const flatDefaultRecordPageLayoutsToCreate =
this.computeFlatDefaultRecordPageLayoutToCreate({
objectMetadata: flatObjectMetadataToCreate,
flatApplication: resolvedOwnerFlatApplication,
recordPageFieldsView: flatRecordPageFieldsViewToCreate,
workspaceId,
});
const validateAndBuildResult =
await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration(
{
@@ -540,12 +569,18 @@ export class ObjectMetadataService extends TypeOrmQueryService<ObjectMetadataEnt
flatEntityToUpdate: [],
},
view: {
flatEntityToCreate: [flatDefaultViewToCreate],
flatEntityToCreate: [
flatDefaultViewToCreate,
flatRecordPageFieldsViewToCreate,
],
flatEntityToDelete: [],
flatEntityToUpdate: [],
},
viewField: {
flatEntityToCreate: flatDefaultViewFieldsToCreate,
flatEntityToCreate: [
...flatDefaultViewFieldsToCreate,
...flatRecordPageFieldsViewFieldsToCreate,
],
flatEntityToDelete: [],
flatEntityToUpdate: [],
},
@@ -562,6 +597,29 @@ export class ObjectMetadataService extends TypeOrmQueryService<ObjectMetadataEnt
flatEntityToDelete: [],
flatEntityToUpdate: [],
},
commandMenuItem: {
flatEntityToCreate: [flatCommandMenuItemToCreate],
flatEntityToDelete: [],
flatEntityToUpdate: [],
},
pageLayout: {
flatEntityToCreate:
flatDefaultRecordPageLayoutsToCreate.pageLayouts,
flatEntityToDelete: [],
flatEntityToUpdate: [],
},
pageLayoutTab: {
flatEntityToCreate:
flatDefaultRecordPageLayoutsToCreate.pageLayoutTabs,
flatEntityToDelete: [],
flatEntityToUpdate: [],
},
pageLayoutWidget: {
flatEntityToCreate:
flatDefaultRecordPageLayoutsToCreate.pageLayoutWidgets,
flatEntityToDelete: [],
flatEntityToUpdate: [],
},
...(isDefined(flatNavigationMenuItemToCreate)
? {
navigationMenuItem: {
@@ -586,100 +644,6 @@ export class ObjectMetadataService extends TypeOrmQueryService<ObjectMetadataEnt
);
}
const commandMenuItemMigrationResult =
await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration(
{
allFlatEntityOperationByMetadataName: {
commandMenuItem: {
flatEntityToCreate: [flatCommandMenuItemToCreate],
flatEntityToDelete: [],
flatEntityToUpdate: [],
},
},
workspaceId,
applicationUniversalIdentifier:
twentyStandardFlatApplication.universalIdentifier,
},
);
if (commandMenuItemMigrationResult.status === 'fail') {
throw new WorkspaceMigrationBuilderException(
commandMenuItemMigrationResult,
'Multiple validation errors occurred while creating command menu item',
);
}
const flatRecordPageFieldsViewToCreate =
this.computeFlatRecordPageFieldsViewToCreate({
objectMetadata: flatObjectMetadataToCreate,
flatApplication: twentyStandardFlatApplication,
});
const flatRecordPageFieldsViewFieldsToCreate =
computeFlatViewFieldsToCreate({
flatApplication: twentyStandardFlatApplication,
objectFlatFieldMetadatas: flatFieldMetadataToCreateOnObject,
labelIdentifierFieldMetadataUniversalIdentifier:
flatObjectMetadataToCreate.labelIdentifierFieldMetadataUniversalIdentifier,
viewUniversalIdentifier:
flatRecordPageFieldsViewToCreate.universalIdentifier,
excludeLabelIdentifier: true,
});
const flatDefaultRecordPageLayoutsToCreate =
this.computeFlatDefaultRecordPageLayoutToCreate({
objectMetadata: flatObjectMetadataToCreate,
flatApplication: twentyStandardFlatApplication,
recordPageFieldsView: flatRecordPageFieldsViewToCreate,
workspaceId,
});
const pageLayoutMigrationResult =
await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration(
{
allFlatEntityOperationByMetadataName: {
view: {
flatEntityToCreate: [flatRecordPageFieldsViewToCreate],
flatEntityToDelete: [],
flatEntityToUpdate: [],
},
viewField: {
flatEntityToCreate: flatRecordPageFieldsViewFieldsToCreate,
flatEntityToDelete: [],
flatEntityToUpdate: [],
},
pageLayout: {
flatEntityToCreate:
flatDefaultRecordPageLayoutsToCreate.pageLayouts,
flatEntityToDelete: [],
flatEntityToUpdate: [],
},
pageLayoutTab: {
flatEntityToCreate:
flatDefaultRecordPageLayoutsToCreate.pageLayoutTabs,
flatEntityToDelete: [],
flatEntityToUpdate: [],
},
pageLayoutWidget: {
flatEntityToCreate:
flatDefaultRecordPageLayoutsToCreate.pageLayoutWidgets,
flatEntityToDelete: [],
flatEntityToUpdate: [],
},
},
workspaceId,
applicationUniversalIdentifier:
twentyStandardFlatApplication.universalIdentifier,
},
);
if (pageLayoutMigrationResult.status === 'fail') {
throw new WorkspaceMigrationBuilderException(
pageLayoutMigrationResult,
'Multiple validation errors occurred while creating page layouts for object',
);
}
const { flatObjectMetadataMaps: recomputedFlatObjectMetadataMaps } =
await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
@@ -737,6 +701,7 @@ export class ObjectMetadataService extends TypeOrmQueryService<ObjectMetadataEnt
visibility: ViewVisibility.WORKSPACE,
createdByUserWorkspaceId: null,
isActive: true,
isSystemSideEffect: true,
universalOverrides: null,
viewFieldUniversalIdentifiers: [],
viewFieldGroupUniversalIdentifiers: [],
@@ -847,6 +812,7 @@ export class ObjectMetadataService extends TypeOrmQueryService<ObjectMetadataEnt
objectMetadata,
workspaceId,
applicationId,
applicationUniversalIdentifier,
flatCommandMenuItemMaps,
}: {
objectMetadata: {
@@ -859,6 +825,7 @@ export class ObjectMetadataService extends TypeOrmQueryService<ObjectMetadataEnt
};
workspaceId: string;
applicationId: string;
applicationUniversalIdentifier: string;
flatCommandMenuItemMaps: {
byUniversalIdentifier: Record<string, FlatCommandMenuItem | undefined>;
};
@@ -876,6 +843,7 @@ export class ObjectMetadataService extends TypeOrmQueryService<ObjectMetadataEnt
objectMetadata,
commandMenuItemId: v4(),
applicationId,
applicationUniversalIdentifier,
workspaceId,
position: nextPosition,
now: new Date().toISOString(),
@@ -909,6 +877,7 @@ export class ObjectMetadataService extends TypeOrmQueryService<ObjectMetadataEnt
flatCommandMenuItemMaps,
workspaceId,
applicationId,
applicationUniversalIdentifier,
}: {
isBeingEnabled: boolean;
isBeingDisabled: boolean;
@@ -918,6 +887,7 @@ export class ObjectMetadataService extends TypeOrmQueryService<ObjectMetadataEnt
};
workspaceId: string;
applicationId: string;
applicationUniversalIdentifier: string;
}): {
commandMenuItemsToCreate: FlatCommandMenuItem[];
commandMenuItemsToUpdate: FlatCommandMenuItem[];
@@ -943,6 +913,7 @@ export class ObjectMetadataService extends TypeOrmQueryService<ObjectMetadataEnt
objectMetadata: existingFlatObjectMetadata,
workspaceId,
applicationId,
applicationUniversalIdentifier,
flatCommandMenuItemMaps,
}),
],
@@ -134,6 +134,7 @@ export const buildDefaultFlatFieldMetadatasForCustomObject = ({
isNullable: true,
isActive: true,
isSystem: false,
isSystemSideEffect: true,
isUIEditable: true,
defaultValue: null,
createdAt: now,
@@ -40,6 +40,7 @@ export const buildDefaultIndexesForCustomObject = ({
indexWhereClause: null,
isCustom: false,
isUnique: false,
isSystemSideEffect: true,
objectMetadataUniversalIdentifier: flatObjectMetadata.universalIdentifier,
universalIdentifier: tsFlatVectorIndexUniversalIdentifier,
updatedAt: createdAt.toISOString(),
@@ -64,6 +64,7 @@ export const computeFlatDefaultRecordPageLayoutToCreate = ({
widgetIds: [widgetId],
widgetUniversalIdentifiers: [widgetUniversalIdentifier],
isActive: true,
isSystemSideEffect: true,
createdAt: now,
updatedAt: now,
deletedAt: null,
@@ -119,6 +120,7 @@ export const computeFlatDefaultRecordPageLayoutToCreate = ({
objectMetadataId: objectMetadata.id,
objectMetadataUniversalIdentifier: objectMetadata.universalIdentifier,
isActive: true,
isSystemSideEffect: true,
createdAt: now,
updatedAt: now,
deletedAt: null,
@@ -141,6 +143,7 @@ export const computeFlatDefaultRecordPageLayoutToCreate = ({
tabUniversalIdentifiers: pageLayoutTabs.map(
(tab) => tab.universalIdentifier,
),
isSystemSideEffect: true,
createdAt: now,
updatedAt: now,
deletedAt: null,
@@ -43,6 +43,7 @@ export const computeFlatRecordPageFieldsViewToCreate = ({
visibility: ViewVisibility.WORKSPACE,
createdByUserWorkspaceId: null,
isActive: true,
isSystemSideEffect: true,
universalOverrides: null,
viewFieldUniversalIdentifiers: [],
viewFieldGroupUniversalIdentifiers: [],
@@ -65,6 +65,7 @@ export const computeFlatViewFieldsToCreate = ({
position: index,
aggregateOperation: null,
isActive: true,
isSystemSideEffect: true,
universalOverrides: null,
applicationUniversalIdentifier: flatApplication.universalIdentifier,
}));