From e9a030f762a43ea25992a439bccf1348abe095b8 Mon Sep 17 00:00:00 2001
From: Paul Rastoin <45004772+prastoin@users.noreply.github.com>
Date: Thu, 9 Jul 2026 17:00:49 +0200
Subject: [PATCH] fix(server): allow relabelling onto a field introduced in the
same manifest sync (#22727)
## Context
Closes twentyhq/core-team-issues#2655.
`computeOrderedMigrationActions` runs `objectMetadata.update` **before**
`fieldMetadata.create`. In a single manifest sync that both introduces a
new field and relabels the object's
`labelIdentifierFieldMetadataUniversalIdentifier` onto that field, the
object update handler resolved the label identifier's universal
identifier against the persisted `flatFieldMetadataMaps` only. Since the
field's `fieldMetadata.create` runs later in the same migration, the
field isn't in the maps yet and the sync failed with `ENTITY_NOT_FOUND`.
The API metadata path is unaffected because create-field and
update-object are separate requests (separate transactions), so the
field is already persisted by the time the object update resolves.
## What this does
`update-object-action-handler.service.ts` now resolves
`labelIdentifierFieldMetadataId` and `imageIdentifierFieldMetadataId`
against the deterministically preallocated field ids first, then falls
back to the persisted flat maps for fields that already exist.
The preallocated ids
(`preallocatedIdByUniversalIdentifierByMetadataName`) are built from
every create action before the migration loop starts
(`buildPreallocatedIdByUniversalIdentifierFromActions`) and are the same
ids `create-field-action-handler` persists the fields with. This is the
same "preallocated-first, then flat maps" resolution that
`resolveUniversalRelationIdentifiersToIds` already uses for modeled
many-to-one relations, so no ordering change or new machinery is needed,
and the single sync stays one atomic transaction.
This does not touch the underlying action ordering or the hand-rolled
label/image identifier handling flagged by the `#2172` TODO;
generalizing those into the relation config remains the follow-up.
## Test
Adds `relabel-onto-new-field-manifest-sync.integration-spec.ts` (used as
the TDD reproduction, now green):
- introducing a field and relabelling onto it in a single sync succeeds,
and the object's `labelIdentifierFieldMetadataId` points at the new
field;
- the split path (introduce in one sync, relabel in the next) still
succeeds and exposes the enriched `labelIdentifierFieldMetadataId`
through the metadata API.
Both cases pass against the fix. `oxlint`, `oxfmt`, and `typecheck` are
clean.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01KJewXMuWYUyrX3YJh2JBDE)_
---
.../update-object-action-handler.service.ts | 76 +++++---
...ew-field-manifest-sync.integration-spec.ts | 184 ++++++++++++++++++
2 files changed, 234 insertions(+), 26 deletions(-)
create mode 100644 packages/twenty-server/test/integration/metadata/suites/application/relabel-onto-new-field-manifest-sync.integration-spec.ts
diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/object/services/update-object-action-handler.service.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/object/services/update-object-action-handler.service.ts
index 5602af10a8..81ffa8a6d5 100644
--- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/object/services/update-object-action-handler.service.ts
+++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/object/services/update-object-action-handler.service.ts
@@ -67,35 +67,21 @@ export class UpdateObjectActionHandlerService extends WorkspaceMigrationRunnerAc
};
if (isDefined(labelIdentifierFieldMetadataUniversalIdentifier)) {
- const flatFieldMetadata = findFlatEntityByUniversalIdentifier({
- flatEntityMaps: allFlatEntityMaps.flatFieldMetadataMaps,
- universalIdentifier: labelIdentifierFieldMetadataUniversalIdentifier,
- });
-
- if (!isDefined(flatFieldMetadata)) {
- throw new FlatEntityMapsException(
- `Could not resolve labelIdentifierFieldMetadataUniversalIdentifier to labelIdentifierFieldMetadataId: no fieldMetadata found for universal identifier ${labelIdentifierFieldMetadataUniversalIdentifier}`,
- FlatEntityMapsExceptionCode.ENTITY_NOT_FOUND,
- );
- }
-
- transpiledUpdate.labelIdentifierFieldMetadataId = flatFieldMetadata.id;
+ transpiledUpdate.labelIdentifierFieldMetadataId =
+ this.resolveFieldMetadataIdOrThrow({
+ context,
+ universalIdentifier: labelIdentifierFieldMetadataUniversalIdentifier,
+ foreignKeyName: 'labelIdentifierFieldMetadataId',
+ });
}
if (isDefined(imageIdentifierFieldMetadataUniversalIdentifier)) {
- const flatFieldMetadata = findFlatEntityByUniversalIdentifier({
- flatEntityMaps: allFlatEntityMaps.flatFieldMetadataMaps,
- universalIdentifier: imageIdentifierFieldMetadataUniversalIdentifier,
- });
-
- if (!isDefined(flatFieldMetadata)) {
- throw new FlatEntityMapsException(
- `Could not resolve imageIdentifierFieldMetadataUniversalIdentifier to imageIdentifierFieldMetadataId: no fieldMetadata found for universal identifier ${imageIdentifierFieldMetadataUniversalIdentifier}`,
- FlatEntityMapsExceptionCode.ENTITY_NOT_FOUND,
- );
- }
-
- transpiledUpdate.imageIdentifierFieldMetadataId = flatFieldMetadata.id;
+ transpiledUpdate.imageIdentifierFieldMetadataId =
+ this.resolveFieldMetadataIdOrThrow({
+ context,
+ universalIdentifier: imageIdentifierFieldMetadataUniversalIdentifier,
+ foreignKeyName: 'imageIdentifierFieldMetadataId',
+ });
}
return {
@@ -106,6 +92,44 @@ export class UpdateObjectActionHandlerService extends WorkspaceMigrationRunnerAc
};
}
+ private resolveFieldMetadataIdOrThrow({
+ context,
+ universalIdentifier,
+ foreignKeyName,
+ }: {
+ context: WorkspaceMigrationActionRunnerArgs;
+ universalIdentifier: string;
+ foreignKeyName: string;
+ }): string {
+ const {
+ allFlatEntityMaps,
+ preallocatedIdByUniversalIdentifierByMetadataName,
+ } = context;
+
+ const preallocatedFieldMetadataId =
+ preallocatedIdByUniversalIdentifierByMetadataName?.fieldMetadata?.[
+ universalIdentifier
+ ];
+
+ if (isDefined(preallocatedFieldMetadataId)) {
+ return preallocatedFieldMetadataId;
+ }
+
+ const flatFieldMetadata = findFlatEntityByUniversalIdentifier({
+ flatEntityMaps: allFlatEntityMaps.flatFieldMetadataMaps,
+ universalIdentifier,
+ });
+
+ if (!isDefined(flatFieldMetadata)) {
+ throw new FlatEntityMapsException(
+ `Could not resolve ${foreignKeyName}: no fieldMetadata found for universal identifier ${universalIdentifier}`,
+ FlatEntityMapsExceptionCode.ENTITY_NOT_FOUND,
+ );
+ }
+
+ return flatFieldMetadata.id;
+ }
+
async executeForMetadata(
context: WorkspaceMigrationActionRunnerContext,
): Promise {
diff --git a/packages/twenty-server/test/integration/metadata/suites/application/relabel-onto-new-field-manifest-sync.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/application/relabel-onto-new-field-manifest-sync.integration-spec.ts
new file mode 100644
index 0000000000..def6428087
--- /dev/null
+++ b/packages/twenty-server/test/integration/metadata/suites/application/relabel-onto-new-field-manifest-sync.integration-spec.ts
@@ -0,0 +1,184 @@
+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 { findManyObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/find-many-object-metadata.util';
+import { type ObjectManifest } from 'twenty-shared/application';
+import { FieldMetadataType } from 'twenty-shared/types';
+import { isDefined } from 'twenty-shared/utils';
+import { v4 as uuidv4 } from 'uuid';
+
+const TEST_APP_ID = uuidv4();
+const TEST_ROLE_ID = uuidv4();
+const TEST_OBJECT_ID = uuidv4();
+const NAME_FIELD_ID = uuidv4();
+const CODE_FIELD_ID = uuidv4();
+
+const buildTicketObject = ({
+ labelIdentifierFieldMetadataUniversalIdentifier,
+ additionalFields,
+}: {
+ labelIdentifierFieldMetadataUniversalIdentifier: string;
+ additionalFields: ObjectManifest['fields'];
+}) =>
+ buildDefaultObjectManifest({
+ applicationUniversalIdentifier: TEST_APP_ID,
+ universalIdentifier: TEST_OBJECT_ID,
+ nameSingular: 'ticket',
+ namePlural: 'tickets',
+ labelSingular: 'Ticket',
+ labelPlural: 'Tickets',
+ description: 'A support ticket',
+ icon: 'IconTicket',
+ labelIdentifierFieldMetadataUniversalIdentifier,
+ additionalFields,
+ });
+
+const NAME_FIELD: ObjectManifest['fields'][number] = {
+ universalIdentifier: NAME_FIELD_ID,
+ type: FieldMetadataType.TEXT,
+ name: 'name',
+ label: 'Name',
+};
+
+const CODE_FIELD: ObjectManifest['fields'][number] = {
+ universalIdentifier: CODE_FIELD_ID,
+ type: FieldMetadataType.TEXT,
+ name: 'code',
+ label: 'Code',
+};
+
+const buildManifest = (object: ObjectManifest) =>
+ buildBaseManifest({
+ appId: TEST_APP_ID,
+ roleId: TEST_ROLE_ID,
+ overrides: { objects: [object] },
+ });
+
+const findTicketObject = async () => {
+ const { objects } = await findManyObjectMetadata({
+ input: {
+ filter: {},
+ paging: { first: 100 },
+ },
+ gqlFields: `
+ id
+ universalIdentifier
+ labelIdentifierFieldMetadataId
+ fieldsList {
+ id
+ name
+ universalIdentifier
+ }
+ `,
+ expectToFail: false,
+ });
+
+ return objects.find(
+ (object) => object.universalIdentifier === TEST_OBJECT_ID,
+ );
+};
+
+describe('Manifest sync - relabel label identifier onto a newly introduced field', () => {
+ beforeEach(async () => {
+ await setupApplicationForSync({
+ applicationUniversalIdentifier: TEST_APP_ID,
+ name: 'Test Application',
+ description: 'App for testing label identifier relabel on manifest sync',
+ sourcePath: 'test-relabel-onto-new-field',
+ });
+ }, 60000);
+
+ afterEach(async () => {
+ await cleanupApplicationAndAppRegistration({
+ applicationUniversalIdentifier: TEST_APP_ID,
+ });
+ });
+
+ it('should introduce a new field and relabel onto it in a single sync', async () => {
+ await syncApplication({
+ manifest: buildManifest(
+ buildTicketObject({
+ labelIdentifierFieldMetadataUniversalIdentifier: NAME_FIELD_ID,
+ additionalFields: [NAME_FIELD],
+ }),
+ ),
+ expectToFail: false,
+ });
+
+ const ticketAfterFirstSync = await findTicketObject();
+
+ expect(ticketAfterFirstSync).toBeDefined();
+
+ const nameField = ticketAfterFirstSync?.fieldsList?.find(
+ (field) => field.name === 'name',
+ );
+
+ expect(nameField).toBeDefined();
+ expect(ticketAfterFirstSync?.labelIdentifierFieldMetadataId).toBe(
+ nameField?.id,
+ );
+
+ await syncApplication({
+ manifest: buildManifest(
+ buildTicketObject({
+ labelIdentifierFieldMetadataUniversalIdentifier: CODE_FIELD_ID,
+ additionalFields: [NAME_FIELD, CODE_FIELD],
+ }),
+ ),
+ expectToFail: false,
+ });
+
+ const ticketAfterSecondSync = await findTicketObject();
+ const codeField = ticketAfterSecondSync?.fieldsList?.find(
+ (field) => field.name === 'code',
+ );
+
+ expect(codeField).toBeDefined();
+ expect(ticketAfterSecondSync?.labelIdentifierFieldMetadataId).toBe(
+ codeField?.id,
+ );
+ }, 60000);
+
+ it('should relabel onto a newly introduced field when introduction and relabel are split across two syncs, exposing the enriched labelIdentifierFieldMetadataId through the metadata API', async () => {
+ await syncApplication({
+ manifest: buildManifest(
+ buildTicketObject({
+ labelIdentifierFieldMetadataUniversalIdentifier: NAME_FIELD_ID,
+ additionalFields: [NAME_FIELD],
+ }),
+ ),
+ expectToFail: false,
+ });
+
+ await syncApplication({
+ manifest: buildManifest(
+ buildTicketObject({
+ labelIdentifierFieldMetadataUniversalIdentifier: NAME_FIELD_ID,
+ additionalFields: [NAME_FIELD, CODE_FIELD],
+ }),
+ ),
+ expectToFail: false,
+ });
+
+ await syncApplication({
+ manifest: buildManifest(
+ buildTicketObject({
+ labelIdentifierFieldMetadataUniversalIdentifier: CODE_FIELD_ID,
+ additionalFields: [NAME_FIELD, CODE_FIELD],
+ }),
+ ),
+ expectToFail: false,
+ });
+
+ const ticket = await findTicketObject();
+ const codeField = ticket?.fieldsList?.find(
+ (field) => field.name === 'code',
+ );
+
+ expect(codeField).toBeDefined();
+ expect(isDefined(ticket?.labelIdentifierFieldMetadataId)).toBe(true);
+ expect(ticket?.labelIdentifierFieldMetadataId).toBe(codeField?.id);
+ }, 60000);
+});