Files
twenty/packages/twenty-server/src/engine/workspace-manager/standard-objects-prefill-data/utils/prefill-front-component-command-menu-items.util.ts
T
Weiko 3ee93b5ec9 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. -->
2026-06-17 15:56:27 +02:00

129 lines
4.9 KiB
TypeScript

import { v4 } from 'uuid';
import { isDefined } from 'twenty-shared/utils';
import { type ApplicationService } from 'src/engine/core-modules/application/application.service';
import { CommandMenuItemAvailabilityType } from 'src/engine/metadata-modules/command-menu-item/enums/command-menu-item-availability-type.enum';
import { EngineComponentKey } from 'src/engine/metadata-modules/command-menu-item/enums/engine-component-key.enum';
import { type FlatCommandMenuItem } from 'src/engine/metadata-modules/flat-command-menu-item/types/flat-command-menu-item.type';
import { type WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util';
import {
getSeedFrontComponentCommandMenuItemDefinitions,
getSeedFrontComponentIds,
} from 'src/engine/workspace-manager/standard-objects-prefill-data/utils/prefill-front-component-definitions.util';
import { type WorkspaceMigrationValidateBuildAndRunService } from 'src/engine/workspace-manager/workspace-migration/services/workspace-migration-validate-build-and-run-service';
export const prefillFrontComponentCommandMenuItems = async ({
workspaceId,
applicationService,
flatEntityMapsCacheService,
workspaceMigrationValidateBuildAndRunService,
}: {
workspaceId: string;
applicationService: ApplicationService;
flatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService;
workspaceMigrationValidateBuildAndRunService: WorkspaceMigrationValidateBuildAndRunService;
}): Promise<void> => {
const { helloWorldId } = getSeedFrontComponentIds(workspaceId);
const { flatCommandMenuItemMaps, flatFrontComponentMaps } =
await flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps({
workspaceId,
flatMapsKeys: ['flatCommandMenuItemMaps', 'flatFrontComponentMaps'],
});
const frontComponent = findFlatEntityByIdInFlatEntityMaps({
flatEntityId: helloWorldId,
flatEntityMaps: flatFrontComponentMaps,
});
if (!isDefined(frontComponent)) {
return;
}
const { workspaceCustomFlatApplication } =
await applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow(
{ workspaceId },
);
const definitions =
getSeedFrontComponentCommandMenuItemDefinitions(workspaceId);
const now = new Date().toISOString();
const flatCommandMenuItemsToCreate: FlatCommandMenuItem[] = definitions
.filter(
(definition) =>
!isDefined(
flatCommandMenuItemMaps.byUniversalIdentifier[
definition.universalIdentifier
],
),
)
.map((definition) => {
const definitionFrontComponent = findFlatEntityByIdInFlatEntityMaps({
flatEntityId: definition.frontComponentId,
flatEntityMaps: flatFrontComponentMaps,
});
return {
id: v4(),
universalIdentifier: definition.universalIdentifier,
applicationId: frontComponent.applicationId,
applicationUniversalIdentifier:
frontComponent.applicationUniversalIdentifier,
workspaceId,
workflowVersionId: null,
frontComponentId: definition.frontComponentId,
frontComponentUniversalIdentifier:
definitionFrontComponent?.universalIdentifier ?? null,
engineComponentKey: EngineComponentKey.FRONT_COMPONENT_RENDERER,
label: definition.label,
icon: definition.icon,
shortLabel: definition.label,
position: definition.position,
isPinned: definition.isPinned ?? false,
availabilityType: CommandMenuItemAvailabilityType.GLOBAL,
conditionalAvailabilityExpression: null,
availabilityObjectMetadataId: null,
availabilityObjectMetadataUniversalIdentifier: null,
payload: null,
hotKeys: null,
pageLayoutId: definition.pageLayoutId ?? null,
pageLayoutUniversalIdentifier: definition.pageLayoutId ?? null,
isActive: true,
isSystemSideEffect: false,
overrides: null,
universalOverrides: null,
createdAt: now,
updatedAt: now,
};
});
if (flatCommandMenuItemsToCreate.length === 0) {
return;
}
const result =
await workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration(
{
allFlatEntityOperationByMetadataName: {
commandMenuItem: {
flatEntityToCreate: flatCommandMenuItemsToCreate,
flatEntityToDelete: [],
flatEntityToUpdate: [],
},
},
workspaceId,
applicationUniversalIdentifier:
workspaceCustomFlatApplication.universalIdentifier,
},
);
if (result.status === 'fail') {
throw new Error(
`Failed to create front component command menu items for workspace ${workspaceId}: ${JSON.stringify(result, null, 2)}`,
);
}
};