isCustom deprecation for Objects and Fields (#21228)

## Context

`isCustom` was a legacy denormalized boolean on `ObjectMetadataEntity`
and `FieldMetadataEntity`.
Now that every metadata row carries `applicationId` (via
`SyncableEntity`), "is this custom" is fully derivable, and the stored
boolean was a redundant second source of truth that could drift.

The real meaning of `isCustom` is **"the owning application is not the
twenty-standard application"** — i.e. `!belongsToTwentyStandardApp`.
Note this is *not* "belongs to the workspace custom app" as I initially
thought: third-party-application
objects/fields are custom too. 
The standard application has a globally stable `universalIdentifier`, so
the value derives with no per-workspace lookup.

## Changed
## `isCustom` checks — before → after

`isCustom` is no longer a stored column. The table below lists every
site that branched on it and how it resolves now. The unifying rule:
`isCustom ≡
!isTwentyStandardApplicationUniversalIdentifier(applicationUniversalIdentifier)`.

### Server — behavioural checks

| Location | Purpose | Before | Now |
|---|---|---|---|
| `utils/compute-object-target-table.util.ts` | Physical table name `_`
prefix | `computeTableName(nameSingular, objectMetadata.isCustom)` |
derives from `applicationUniversalIdentifier` (single source for all
table-name callers) |
| `twenty-orm/factories/entity-schema.factory.ts` +
`…/entity-schema-metadata.type.ts` | ORM table name (hot path) |
`object.isCustom` | `object.applicationId !== standardApplicationId`
(computed in `buildEntitySchemaMetadataMaps`) |
|
`twenty-orm/repository/workspace-{delete,soft-delete,update}-query-builder.ts`
| Table name for mutations | `computeTableName(nameSingular,
objectMetadata.isCustom)` | `computeObjectTargetTable(objectMetadata)` |
| `index-metadata/utils/generate-deterministic-index-name-v2.ts` | Index
name hash (must stay bit-identical) | `flatObjectMetadata.isCustom` |
derives from `applicationUniversalIdentifier` |
| `object-metadata/object-record-count.service.ts` | Table name for
record count | `computeTableName(nameSingular, isCustom)` |
`computeObjectTargetTable(flatObjectMetadata)` |
|
`workspace-manager/dev-seeder/data/services/dev-seeder-data.service.ts`
| Match seed config by table name | `computeTableName(item.nameSingular,
item.isCustom)` | `computeObjectTargetTable(item)` |
| `commands/workspace-export/workspace-export.service.ts` +
`…/utils/generate-workspace-schema-ddl.util.ts` | Export table name (raw
entity) | `objectMetadata.isCustom` |
`!isTwentyStandard…(objectMetadata.application?.universalIdentifier)` |
|
`flat-field-metadata/services/flat-field-metadata-type-validator.service.ts`
| Block users creating reserved field types |
`args.flatEntityToValidate.isCustom` |
`!args.flatEntityToValidate.isSystem` |
| `api/common/.../common-create-many-query-runner.service.ts` | Don't
let client overwrite system `createdBy` |
`createdByFieldMetadata.isCustom === false` |
`createdByFieldMetadata.isSystem === true` |
|
`field-metadata/utils/resolve-field-metadata-standard-override.util.ts`
| Skip i18n/overrides for custom fields | `if (fieldMetadata.isCustom)
return raw` | **removed** — falls through on
`isDefined(standardOverrides)` |
|
`object-metadata/utils/resolve-object-metadata-standard-override.util.ts`
| Skip i18n/overrides for custom objects | `if (objectMetadata.isCustom)
return raw` | **removed** — same fall-through |
|
`command-menu-item/utils/build-navigation-interpolation-context.util.ts`
| Override context for nav labels | passed `isCustom` into resolver |
dropped (resolver no longer needs it) |
| `api/common/.../data-arg-processor.service.ts` | `isCustom` for
record-position table name | `flatObjectMetadata.isCustom` | derives
from `applicationUniversalIdentifier` |
| `metadata-modules/minimal-metadata/minimal-metadata.service.ts` |
Minimal DTO + override context | `flatObjectMetadata.isCustom` | derives
from `applicationUniversalIdentifier` |
|
`commands/upgrade-version-command/1-23/…backfill-record-page-layouts.command.ts`
| Filter to custom objects | `objectMetadata.isCustom` |
`!isTwentyStandard…(applicationUniversalIdentifier)` |

### Server — DTO / API population

| Location | Before | Now |
|---|---|---|
|
`flat-object-metadata/utils/from-flat-object-metadata-to-object-metadata-dto.util.ts`
| passthrough `isCustom` | derives from `applicationUniversalIdentifier`
|
|
`flat-field-metadata/utils/from-flat-field-metadata-to-field-metadata-dto.util.ts`
| passthrough `isCustom` | derives from `applicationUniversalIdentifier`
|
|
`object-metadata/utils/from-object-metadata-entity-to-object-metadata-dto.util.ts`
(REST) | `entity.isCustom` | `entity.applicationId !==
standardApplicationId` |
|
`field-metadata/utils/from-field-metadata-entity-to-field-metadata-dto.util.ts`
(REST) | `entity.isCustom` | `entity.applicationId !==
standardApplicationId` |
| `dataloaders/dataloader.service.ts` | passed
`flatFieldMetadata.isCustom` into override resolver | dropped (resolver
no longer needs it) |

> REST controllers (`object-metadata.controller.ts`,
`field-metadata.controller.ts`) resolve `standardApplicationId` once per
request from the cached `flatApplicationMaps`.

### Frontend

| Location | Purpose | Before | Now |
|---|---|---|---|
| `settings/.../SettingsObjectFieldDisabledActionDropdown.tsx` | Whether
an inactive field is deletable | `isDeletable = isCustomField` |
`isDeletable = isCustomField && !isSystemField` |

### Unchanged (out of scope)

`isCustom` on `IndexMetadata` / `View` / `Skill` / `Agent` and their
guards still read the persisted column.

Breaking change is on the isCustom filter on field and object APIs, this
is never used in the FE and unlikely used by external consumers
This commit is contained in:
Weiko
2026-06-09 15:57:19 +02:00
committed by GitHub
parent 7530775dc1
commit 9c66975520
129 changed files with 502 additions and 538 deletions
@@ -1,6 +1,8 @@
import { type AllMetadataName } from 'twenty-shared/metadata';
import { type FormatRecordSerializedRelationProperties } from 'twenty-shared/types';
import { type MakeWasRemovedInUpgradePropertiesOptional } from 'src/engine/core-modules/upgrade/decorators/was-removed-in-upgrade.decorator';
import { type ALL_MANY_TO_ONE_METADATA_RELATIONS } from 'src/engine/metadata-modules/flat-entity/constant/all-many-to-one-metadata-relations.constant';
import { type ALL_ONE_TO_MANY_METADATA_RELATIONS } from 'src/engine/metadata-modules/flat-entity/constant/all-one-to-many-metadata-relations.constant';
import { type AddSuffixToEntityManyToOneProperties } from 'src/engine/metadata-modules/flat-entity/types/add-suffix-to-entity-many-to-one-properties.type';
@@ -43,7 +45,7 @@ export type UniversalFlatEntityFrom<
TMetadataName extends AllMetadataName =
FromMetadataEntityToMetadataName<TEntity>,
> = Omit<
TEntity,
MakeWasRemovedInUpgradePropertiesOptional<TEntity>,
| 'applicationId'
| 'workspaceId'
| 'id'
@@ -1,4 +1,4 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`flatEntityDeletedCreatedUpdatedMatrixDispatcher It should detect a created entity 1`] = `
{
@@ -17,7 +17,6 @@ exports[`flatEntityDeletedCreatedUpdatedMatrixDispatcher It should detect a crea
"icon": "icon",
"id": "field-id-1",
"isActive": true,
"isCustom": true,
"isLabelSyncedWithName": false,
"isNullable": true,
"isSystem": false,
@@ -82,7 +81,6 @@ exports[`flatEntityDeletedCreatedUpdatedMatrixDispatcher It should detect a dele
"icon": "icon",
"id": "field-id-1",
"isActive": true,
"isCustom": true,
"isLabelSyncedWithName": false,
"isNullable": true,
"isSystem": false,
@@ -161,7 +159,6 @@ exports[`flatEntityDeletedCreatedUpdatedMatrixDispatcher It should detect create
"icon": "icon",
"id": "field-id-3",
"isActive": true,
"isCustom": true,
"isLabelSyncedWithName": false,
"isNullable": true,
"isSystem": false,
@@ -212,7 +209,6 @@ exports[`flatEntityDeletedCreatedUpdatedMatrixDispatcher It should detect create
"icon": "icon",
"id": "field-id-2",
"isActive": true,
"isCustom": true,
"isLabelSyncedWithName": false,
"isNullable": true,
"isSystem": false,
@@ -37,7 +37,6 @@ describe('addUniversalFlatEntityToUniversalFlatEntityAndRelatedEntityMapsThrough
icon: 'icon',
isActive: true,
isAuditLogged: true,
isCustom: true,
isLabelSyncedWithName: false,
isRemote: false,
isSearchable: true,
@@ -80,7 +79,6 @@ describe('addUniversalFlatEntityToUniversalFlatEntityAndRelatedEntityMapsThrough
description: 'default flat field metadata description',
icon: 'icon',
isActive: true,
isCustom: true,
name: 'flatFieldMetadataName',
label: 'flat field metadata label',
isNullable: true,
@@ -33,7 +33,6 @@ describe('deleteUniversalFlatEntityFromUniversalFlatEntityAndRelatedEntityMapsTh
icon: 'icon',
isActive: true,
isAuditLogged: true,
isCustom: true,
isLabelSyncedWithName: false,
isRemote: false,
isSearchable: true,
@@ -76,7 +75,6 @@ describe('deleteUniversalFlatEntityFromUniversalFlatEntityAndRelatedEntityMapsTh
description: 'default flat field metadata description',
icon: 'icon',
isActive: true,
isCustom: true,
name: 'flatFieldMetadataName',
label: 'flat field metadata label',
isNullable: true,
@@ -27,7 +27,6 @@ describe('flatEntityToScalarFlatEntity', () => {
standardOverrides: null,
settings: null,
universalSettings: null,
isCustom: true,
isSystem: false,
isUIReadOnly: false,
isNullable: true,
@@ -56,7 +55,6 @@ describe('flatEntityToScalarFlatEntity', () => {
"icon": "IconTest",
"id": "field-metadata-id",
"isActive": true,
"isCustom": true,
"isLabelSyncedWithName": false,
"isNullable": true,
"isSystem": false,
@@ -15,7 +15,7 @@ export const getWorkspaceSchemaContextForMigration = ({
workspaceId: string;
objectMetadata: Pick<
FlatObjectMetadata | UniversalFlatObjectMetadata,
'nameSingular' | 'isCustom'
'nameSingular' | 'applicationUniversalIdentifier'
>;
}): WorkspaceSchemaContextForMigration => {
return {