Files
twenty/packages/twenty-server/test/integration/metadata/suites/application/successful-sync-application-workspace-migration.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

262 lines
8.0 KiB
TypeScript

import { buildBaseManifest } from 'test/integration/metadata/suites/application/utils/build-base-manifest.util';
import { buildDefaultObjectManifest } from 'test/integration/metadata/suites/application/utils/build-default-object-manifest.util';
import { cleanupApplicationAndAppRegistration } from 'test/integration/metadata/suites/application/utils/cleanup-application-and-app-registration.util';
import { setupApplicationForSync } from 'test/integration/metadata/suites/application/utils/setup-application-for-sync.util';
import { syncApplication } from 'test/integration/metadata/suites/application/utils/sync-application.util';
import { findManyObjectMetadataWithIndexes } from 'test/integration/metadata/suites/object-metadata/utils/find-many-object-metadata-with-indexes.util';
import { findManyObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/find-many-object-metadata.util';
import { findRoles } from 'test/integration/metadata/suites/role/utils/find-roles.util';
import { findSkills } from 'test/integration/metadata/suites/skill/utils/find-skills.util';
import { extractRecordIdsAndDatesAsExpectAny } from 'test/utils/extract-record-ids-and-dates-as-expect-any';
import { type FieldManifest, type Manifest } from 'twenty-shared/application';
import { STANDARD_OBJECTS } from 'twenty-shared/metadata';
import { FieldMetadataType } from 'twenty-shared/types';
import { v4 as uuidv4 } from 'uuid';
const TEST_APP_ID = uuidv4();
const TEST_ROLE_ID = uuidv4();
const TEST_FIELD_ID = uuidv4();
const TEST_SKILL_ID = uuidv4();
const TEST_OBJECT = buildDefaultObjectManifest({
nameSingular: 'ticket',
namePlural: 'tickets',
labelSingular: 'Ticket',
labelPlural: 'Tickets',
description: 'A support ticket',
icon: 'IconTicket',
});
const TEST_SKILL = {
universalIdentifier: TEST_SKILL_ID,
name: 'test-skill',
label: 'Test Skill',
description: 'A skill for testing',
icon: 'IconBrain',
content: '# Test Skill\n\nThis is a test skill.',
};
const TEST_FIELD: FieldManifest = {
universalIdentifier: TEST_FIELD_ID,
type: FieldMetadataType.TEXT,
name: 'description',
label: 'Description',
description: 'Ticket description',
icon: 'IconFileDescription',
objectUniversalIdentifier: TEST_OBJECT.universalIdentifier,
};
const buildManifest = (
overrides?: Partial<Pick<Manifest, 'fields' | 'skills' | 'objects'>>,
) =>
buildBaseManifest({
appId: TEST_APP_ID,
roleId: TEST_ROLE_ID,
overrides: {
objects: [TEST_OBJECT],
skills: [TEST_SKILL],
fields: [TEST_FIELD],
...overrides,
},
});
describe('syncApplication', () => {
beforeEach(async () => {
await setupApplicationForSync({
applicationUniversalIdentifier: TEST_APP_ID,
name: 'Test Application',
description: 'A test application',
sourcePath: 'test-sync',
});
}, 60000);
afterEach(async () => {
await cleanupApplicationAndAppRegistration({
applicationUniversalIdentifier: TEST_APP_ID,
});
});
it('should return workspace migration actions on initial sync then on second sync with field rename and new role', async () => {
const { data: firstSyncData } = await syncApplication({
manifest: buildManifest(),
expectToFail: false,
});
expect(firstSyncData).toMatchSnapshot(
extractRecordIdsAndDatesAsExpectAny(firstSyncData),
);
// Verify database state after first sync
const { objects: objectsAfterSync } = await findManyObjectMetadata({
input: {
filter: {},
paging: { first: 100 },
},
gqlFields:
'id nameSingular namePlural labelSingular labelPlural description icon isCustom',
expectToFail: false,
});
const ticketObject = objectsAfterSync.find(
(obj) => obj.nameSingular === 'ticket',
);
expect(ticketObject).toBeDefined();
expect(ticketObject).toMatchObject({
nameSingular: 'ticket',
namePlural: 'tickets',
labelSingular: 'Ticket',
labelPlural: 'Tickets',
description: 'A support ticket',
icon: 'IconTicket',
isCustom: true,
});
const objects = await findManyObjectMetadataWithIndexes({
expectToFail: false,
});
const fieldsAfterSync = objects.find(
(o) => o.universalIdentifier === TEST_OBJECT.universalIdentifier,
)?.fieldsList;
const descriptionField = fieldsAfterSync?.find(
(f) => f.universalIdentifier === TEST_FIELD_ID,
);
expect(descriptionField).toBeDefined();
expect(descriptionField).toMatchObject({
name: 'description',
label: 'Description',
type: FieldMetadataType.TEXT,
description: 'Ticket description',
icon: 'IconFileDescription',
});
const { data: rolesAfterSync } = await findRoles({
gqlFields: 'id label description universalIdentifier',
expectToFail: false,
});
const testRole = rolesAfterSync.getRoles.find(
(role) => role.universalIdentifier === TEST_ROLE_ID,
);
const { data: skillsAfterSync } = await findSkills({
gqlFields: 'id name label description content icon',
expectToFail: false,
input: undefined,
});
const testSkill = skillsAfterSync.skills.find(
(skill) => skill.name === 'test-skill',
);
expect(testRole).toBeDefined();
expect(testRole).toMatchObject({
label: 'Test Role',
description: 'A test role',
});
expect(testSkill).toBeDefined();
expect(testSkill).toMatchObject({
name: 'test-skill',
label: 'Test Skill',
description: 'A skill for testing',
icon: 'IconBrain',
content: '# Test Skill\n\nThis is a test skill.',
});
}, 60000);
it('should delete old field and create equivalent one when field universalIdentifier changes', async () => {
const originalFieldId = uuidv4();
const updatedFieldId = uuidv4();
const testObject = buildDefaultObjectManifest({
nameSingular: 'ticket',
namePlural: 'tickets',
labelSingular: 'Ticket',
labelPlural: 'Tickets',
description: 'A support ticket',
icon: 'IconTicket',
});
const baseField: FieldManifest = {
universalIdentifier: originalFieldId,
type: FieldMetadataType.TEXT,
name: 'description',
label: 'Description',
description: 'Ticket description',
icon: 'IconFileDescription',
objectUniversalIdentifier: testObject.universalIdentifier,
};
const { data: firstSyncData } = await syncApplication({
manifest: buildBaseManifest({
appId: TEST_APP_ID,
roleId: TEST_ROLE_ID,
overrides: {
objects: [testObject],
fields: [baseField],
},
}),
expectToFail: false,
});
expect(firstSyncData).toMatchSnapshot(
extractRecordIdsAndDatesAsExpectAny(firstSyncData),
);
const { data: secondSyncData } = await syncApplication({
manifest: buildBaseManifest({
appId: TEST_APP_ID,
roleId: TEST_ROLE_ID,
overrides: {
objects: [testObject],
fields: [
{
...baseField,
universalIdentifier: updatedFieldId,
},
],
},
}),
expectToFail: false,
});
expect(secondSyncData).toMatchSnapshot(
extractRecordIdsAndDatesAsExpectAny(secondSyncData),
);
}, 60000);
it('should create a TEXT field on the standard Company object', async () => {
const companyFieldId = uuidv4();
const manifest = buildManifest({
skills: [],
objects: [],
fields: [
{
universalIdentifier: companyFieldId,
type: FieldMetadataType.TEXT,
name: 'industry',
label: 'Industry',
description: 'The industry of the company',
icon: 'IconBuildingFactory2',
objectUniversalIdentifier:
STANDARD_OBJECTS.company.universalIdentifier,
},
],
});
const { data: syncData } = await syncApplication({
manifest,
expectToFail: false,
});
expect(syncData).toMatchSnapshot(
extractRecordIdsAndDatesAsExpectAny(syncData),
);
}, 60000);
});