Files
twenty/packages/twenty-server/test/integration/metadata/suites/object-metadata/rename-custom-object.integration-spec.ts
T
Weiko 9c66975520 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
2026-06-09 13:57:19 +00:00

196 lines
6.1 KiB
TypeScript

import { findManyFieldsMetadataQueryFactory } from 'test/integration/metadata/suites/field-metadata/utils/find-many-fields-metadata-query-factory.util';
import { createOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/create-one-object-metadata.util';
import { deleteOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/delete-one-object-metadata.util';
import { findManyObjectMetadataQueryFactory } from 'test/integration/metadata/suites/object-metadata/utils/find-many-object-metadata-query-factory.util';
import { updateOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/update-one-object-metadata.util';
import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util';
import { FieldMetadataType } from 'twenty-shared/types';
import { capitalize } from 'twenty-shared/utils';
describe('Custom object renaming', () => {
let listingObjectId = '';
const uniqueSuffix = Date.now().toString().slice(-8);
const STANDARD_OBJECT_RELATIONS = [
'noteTarget',
'attachment',
'taskTarget',
'timelineActivity',
];
const standardObjectRelationsMap = STANDARD_OBJECT_RELATIONS.reduce(
(acc, relation) => ({
...acc,
[relation]: {
objectMetadataId: '',
foreignKeyFieldMetadataId: '',
relationFieldMetadataId: '',
},
}),
{},
);
const standardObjectsGraphqlOperation = findManyObjectMetadataQueryFactory({
gqlFields: `
id
nameSingular
`,
input: {
filter: {},
paging: { first: 1000 },
},
});
const fieldsGraphqlOperation = findManyFieldsMetadataQueryFactory({
gqlFields: `
id
name
label
type
object {
id
}
`,
input: {
filter: {},
paging: { first: 1000 },
},
});
// @ts-expect-error legacy noImplicitAny
const fillStandardObjectRelationsMapObjectMetadataId = (standardObjects) => {
STANDARD_OBJECT_RELATIONS.forEach((relation) => {
// @ts-expect-error legacy noImplicitAny
standardObjectRelationsMap[relation].objectMetadataId =
standardObjects.body.data.objects.edges.find(
// @ts-expect-error legacy noImplicitAny
(object) =>
object.node.nameSingular === relation ||
object.node.nameSingular === `target${relation}`,
).node.id;
});
};
it('1. should create one custom object with standard relations', async () => {
// Arrange
const standardObjects = await makeMetadataAPIRequest(
standardObjectsGraphqlOperation,
);
fillStandardObjectRelationsMapObjectMetadataId(standardObjects);
const CUSTOM_OBJECT = {
namePlural: `customObjects${uniqueSuffix}`,
nameSingular: `customObject${uniqueSuffix}`,
labelPlural: `Custom Objects ${uniqueSuffix}`,
labelSingular: `Custom Object ${uniqueSuffix}`,
description: 'Custom object description',
icon: 'IconListNumbers',
isLabelSyncedWithName: false,
};
// Act
const { data } = await createOneObjectMetadata({
expectToFail: false,
input: CUSTOM_OBJECT,
gqlFields: `
id
nameSingular
`,
});
// Assert
expect(data.createOneObject.nameSingular).toBe(CUSTOM_OBJECT.nameSingular);
listingObjectId = data.createOneObject.id;
const fields = await makeMetadataAPIRequest(fieldsGraphqlOperation);
const relationFieldsMetadataForListing = fields.body.data.fields.edges
.filter(
// @ts-expect-error legacy noImplicitAny
(field) =>
(field.node.name === `${CUSTOM_OBJECT.nameSingular}` &&
FieldMetadataType.RELATION) ||
(field.node.name ===
`target${capitalize(CUSTOM_OBJECT.nameSingular)}` &&
FieldMetadataType.MORPH_RELATION),
)
// @ts-expect-error legacy noImplicitAny
.map((field) => field.node);
STANDARD_OBJECT_RELATIONS.forEach((relation) => {
// relation field
const relationFieldMetadata = relationFieldsMetadataForListing.find(
// @ts-expect-error legacy noImplicitAny
(field) =>
field.object.id ===
// @ts-expect-error legacy noImplicitAny
standardObjectRelationsMap[relation].objectMetadataId,
);
const relationFieldMetadataId = relationFieldMetadata?.id;
expect(relationFieldMetadataId).not.toBeUndefined();
// @ts-expect-error legacy noImplicitAny
standardObjectRelationsMap[relation].relationFieldMetadataId =
relationFieldMetadataId;
});
});
it('2. should rename custom object', async () => {
// Arrange
const HOUSE_NAME_SINGULAR = `house${uniqueSuffix}`;
const HOUSE_NAME_PLURAL = `houses${uniqueSuffix}`;
const HOUSE_LABEL_SINGULAR = `House ${uniqueSuffix}`;
const HOUSE_LABEL_PLURAL = `Houses ${uniqueSuffix}`;
// Act
const { data } = await updateOneObjectMetadata({
expectToFail: false,
gqlFields: `
nameSingular
labelSingular
namePlural
labelPlural
`,
input: {
idToUpdate: listingObjectId,
updatePayload: {
nameSingular: HOUSE_NAME_SINGULAR,
namePlural: HOUSE_NAME_PLURAL,
labelSingular: HOUSE_LABEL_SINGULAR,
labelPlural: HOUSE_LABEL_PLURAL,
},
},
});
// Assert
expect(data.updateOneObject.nameSingular).toBe(HOUSE_NAME_SINGULAR);
expect(data.updateOneObject.namePlural).toBe(HOUSE_NAME_PLURAL);
expect(data.updateOneObject.labelSingular).toBe(HOUSE_LABEL_SINGULAR);
expect(data.updateOneObject.labelPlural).toBe(HOUSE_LABEL_PLURAL);
});
it('3. should delete custom object', async () => {
await updateOneObjectMetadata({
expectToFail: false,
input: {
idToUpdate: listingObjectId,
updatePayload: {
isActive: false,
},
},
});
const { data } = await deleteOneObjectMetadata({
input: {
idToDelete: listingObjectId,
},
});
expect(data.deleteOneObject.id).toBe(listingObjectId);
});
});