diff --git a/.github/workflows/ci-cross-version-upgrade.yaml b/.github/workflows/ci-cross-version-upgrade.yaml index 356b0ab074..7182d99f3d 100644 --- a/.github/workflows/ci-cross-version-upgrade.yaml +++ b/.github/workflows/ci-cross-version-upgrade.yaml @@ -221,6 +221,9 @@ jobs: - name: Server / Build run: npx nx build twenty-server + - name: Server / Flush cache + run: npx nx run twenty-server:command-no-deps -- cache:flush + - name: Server / Run upgrade command run: npx nx run twenty-server:command-no-deps -- upgrade --verbose diff --git a/packages/twenty-sdk/src/cli/__tests__/apps/minimal-app/__integration__/app-dev/expected-manifest.ts b/packages/twenty-sdk/src/cli/__tests__/apps/minimal-app/__integration__/app-dev/expected-manifest.ts index 05223b12d5..3fce9a28ba 100644 --- a/packages/twenty-sdk/src/cli/__tests__/apps/minimal-app/__integration__/app-dev/expected-manifest.ts +++ b/packages/twenty-sdk/src/cli/__tests__/apps/minimal-app/__integration__/app-dev/expected-manifest.ts @@ -122,16 +122,6 @@ export const EXPECTED_MANIFEST: Manifest = { label: 'Title', name: 'title', }, - { - defaultValue: 'uuid', - description: 'Id', - icon: 'Icon123', - isNullable: false, - label: 'Id', - name: 'id', - type: FieldMetadataType.UUID, - universalIdentifier: 'a717a3ba-e257-5269-95c4-1b8ca4cb8bcf', - }, { defaultValue: null, description: 'Name', @@ -142,82 +132,6 @@ export const EXPECTED_MANIFEST: Manifest = { type: FieldMetadataType.TEXT, universalIdentifier: '9a7f457a-6c15-581e-8f28-e2c680f75f97', }, - { - defaultValue: 'now', - description: 'Creation date', - icon: 'IconCalendar', - isNullable: false, - label: 'Creation date', - name: 'createdAt', - type: FieldMetadataType.DATE_TIME, - universalIdentifier: 'f24a236a-e36a-5bab-91db-d43e3f4b8247', - }, - { - defaultValue: 'now', - description: 'Last time the record was changed', - icon: 'IconCalendarClock', - isNullable: false, - label: 'Last update', - name: 'updatedAt', - type: FieldMetadataType.DATE_TIME, - universalIdentifier: '875c1f2e-19f0-5200-96ba-ac1cb4c1fd33', - }, - { - defaultValue: null, - description: 'Deletion date', - icon: 'IconCalendarClock', - isNullable: true, - label: 'Deleted at', - name: 'deletedAt', - type: FieldMetadataType.DATE_TIME, - universalIdentifier: 'cc58b328-40e1-5087-b550-ad700e53490b', - }, - { - defaultValue: { - name: "''", - source: "'MANUAL'", - }, - description: 'The creator of the record', - icon: 'IconCreativeCommonsSa', - isNullable: false, - label: 'Created by', - name: 'createdBy', - type: FieldMetadataType.ACTOR, - universalIdentifier: '6f34f940-966a-52c2-a8f2-f2f04115d88e', - }, - { - defaultValue: { - name: "''", - source: "'MANUAL'", - }, - description: 'The workspace member who last updated the record', - icon: 'IconUserCircle', - isNullable: false, - label: 'Updated by', - name: 'updatedBy', - type: FieldMetadataType.ACTOR, - universalIdentifier: '37515dfb-8ae0-5326-a391-9e66205f6530', - }, - { - defaultValue: 0, - description: 'Position', - icon: 'IconHierarchy2', - isNullable: false, - label: 'Position', - name: 'position', - type: FieldMetadataType.POSITION, - universalIdentifier: 'a418b503-74d0-500d-b4c0-75f364394e20', - }, - { - defaultValue: null, - description: 'Search vector', - icon: 'IconSearch', - isNullable: true, - label: 'Search vector', - name: 'searchVector', - type: FieldMetadataType.TS_VECTOR, - universalIdentifier: '45718beb-e0d7-5bde-811c-29ff01b0fcfd', - }, { name: 'timelineActivities', label: 'Timeline Activities', diff --git a/packages/twenty-sdk/src/cli/__tests__/apps/rich-app/__integration__/app-dev/expected-manifest.ts b/packages/twenty-sdk/src/cli/__tests__/apps/rich-app/__integration__/app-dev/expected-manifest.ts index 3f0af021c2..61a7333b15 100644 --- a/packages/twenty-sdk/src/cli/__tests__/apps/rich-app/__integration__/app-dev/expected-manifest.ts +++ b/packages/twenty-sdk/src/cli/__tests__/apps/rich-app/__integration__/app-dev/expected-manifest.ts @@ -755,16 +755,6 @@ export const EXPECTED_MANIFEST: Manifest = { type: FieldType.TEXT, universalIdentifier: 'b0b1b2b3-b4b5-4000-8000-000000000003', }, - { - defaultValue: 'uuid', - description: 'Id', - icon: 'Icon123', - isNullable: false, - label: 'Id', - name: 'id', - type: FieldMetadataType.UUID, - universalIdentifier: 'a4429f96-5745-511a-b246-67da9f920452', - }, { defaultValue: null, description: 'Name', @@ -775,82 +765,6 @@ export const EXPECTED_MANIFEST: Manifest = { type: FieldMetadataType.TEXT, universalIdentifier: '10452ca3-30cb-56fa-9e58-73f7b1c9fd65', }, - { - defaultValue: 'now', - description: 'Creation date', - icon: 'IconCalendar', - isNullable: false, - label: 'Creation date', - name: 'createdAt', - type: FieldMetadataType.DATE_TIME, - universalIdentifier: '777fa853-2198-5623-96f3-119ef5707ad3', - }, - { - defaultValue: 'now', - description: 'Last time the record was changed', - icon: 'IconCalendarClock', - isNullable: false, - label: 'Last update', - name: 'updatedAt', - type: FieldMetadataType.DATE_TIME, - universalIdentifier: 'e1715fe9-34e1-572c-b62c-f39d737a17df', - }, - { - defaultValue: null, - description: 'Deletion date', - icon: 'IconCalendarClock', - isNullable: true, - label: 'Deleted at', - name: 'deletedAt', - type: FieldMetadataType.DATE_TIME, - universalIdentifier: 'e3b13370-7241-55f1-ab9a-346b57bf4dcf', - }, - { - defaultValue: { - name: "''", - source: "'MANUAL'", - }, - description: 'The creator of the record', - icon: 'IconCreativeCommonsSa', - isNullable: false, - label: 'Created by', - name: 'createdBy', - type: FieldMetadataType.ACTOR, - universalIdentifier: 'd2e3e238-9bfd-5845-9fe8-3e58df09f246', - }, - { - defaultValue: { - name: "''", - source: "'MANUAL'", - }, - description: 'The workspace member who last updated the record', - icon: 'IconUserCircle', - isNullable: false, - label: 'Updated by', - name: 'updatedBy', - type: FieldMetadataType.ACTOR, - universalIdentifier: '690003ae-f649-537e-b124-d21ef0ee86a1', - }, - { - defaultValue: 0, - description: 'Position', - icon: 'IconHierarchy2', - isNullable: false, - label: 'Position', - name: 'position', - type: FieldMetadataType.POSITION, - universalIdentifier: '4b6d383d-4e84-592e-b1ae-30ae27139302', - }, - { - defaultValue: null, - description: 'Search vector', - icon: 'IconSearch', - isNullable: true, - label: 'Search vector', - name: 'searchVector', - type: FieldMetadataType.TS_VECTOR, - universalIdentifier: 'a92d59aa-23a1-5191-b840-933183c14846', - }, { description: 'Root notes tied to the RootNote', icon: 'IconBuildingSkyscraper', @@ -937,16 +851,6 @@ export const EXPECTED_MANIFEST: Manifest = { type: FieldType.DATE_TIME, universalIdentifier: 'e2a2b3c4-5e6f-4a7b-8c9d-0e1f2a3b4c5e', }, - { - defaultValue: 'uuid', - description: 'Id', - icon: 'Icon123', - isNullable: false, - label: 'Id', - name: 'id', - type: FieldMetadataType.UUID, - universalIdentifier: 'fe2cec74-a43b-5910-8dd6-7a737fb869ae', - }, { defaultValue: null, description: 'Name', @@ -957,82 +861,6 @@ export const EXPECTED_MANIFEST: Manifest = { type: FieldMetadataType.TEXT, universalIdentifier: 'f485cd3a-4c55-5b8f-b926-a83e5e6ba651', }, - { - defaultValue: 'now', - description: 'Creation date', - icon: 'IconCalendar', - isNullable: false, - label: 'Creation date', - name: 'createdAt', - type: FieldMetadataType.DATE_TIME, - universalIdentifier: '729b0d02-9030-5440-bd4e-f34b46d5d031', - }, - { - defaultValue: 'now', - description: 'Last time the record was changed', - icon: 'IconCalendarClock', - isNullable: false, - label: 'Last update', - name: 'updatedAt', - type: FieldMetadataType.DATE_TIME, - universalIdentifier: '42381c24-6337-5654-8c44-6649fc6b06a8', - }, - { - defaultValue: null, - description: 'Deletion date', - icon: 'IconCalendarClock', - isNullable: true, - label: 'Deleted at', - name: 'deletedAt', - type: FieldMetadataType.DATE_TIME, - universalIdentifier: '5117f25c-d3a2-533b-b3d6-39a424e88d2a', - }, - { - defaultValue: { - name: "''", - source: "'MANUAL'", - }, - description: 'The creator of the record', - icon: 'IconCreativeCommonsSa', - isNullable: false, - label: 'Created by', - name: 'createdBy', - type: FieldMetadataType.ACTOR, - universalIdentifier: '48df9616-8671-522f-87c7-efcd3f2e2604', - }, - { - defaultValue: { - name: "''", - source: "'MANUAL'", - }, - description: 'The workspace member who last updated the record', - icon: 'IconUserCircle', - isNullable: false, - label: 'Updated by', - name: 'updatedBy', - type: FieldMetadataType.ACTOR, - universalIdentifier: '5c8eb640-8c91-5712-8b73-85ce5e71abb9', - }, - { - defaultValue: 0, - description: 'Position', - icon: 'IconHierarchy2', - isNullable: false, - label: 'Position', - name: 'position', - type: FieldMetadataType.POSITION, - universalIdentifier: '5a5800dd-743f-533d-ad5c-f983a4bb6324', - }, - { - defaultValue: null, - description: 'Search vector', - icon: 'IconSearch', - isNullable: true, - label: 'Search vector', - name: 'searchVector', - type: FieldMetadataType.TS_VECTOR, - universalIdentifier: 'c4d0beaf-9035-514b-977c-5059b544c1bb', - }, { description: 'Post Card Recipients tied to the PostCardRecipient', icon: 'IconBuildingSkyscraper', @@ -1186,16 +1014,6 @@ export const EXPECTED_MANIFEST: Manifest = { type: FieldType.DATE_TIME, universalIdentifier: 'e06abe72-5b44-4e7f-93be-afc185a3c433', }, - { - defaultValue: 'uuid', - description: 'Id', - icon: 'Icon123', - isNullable: false, - label: 'Id', - name: 'id', - type: FieldMetadataType.UUID, - universalIdentifier: 'e61745a2-969c-53d1-a809-6c583e9192d5', - }, { defaultValue: null, description: 'Name', @@ -1206,82 +1024,6 @@ export const EXPECTED_MANIFEST: Manifest = { type: FieldMetadataType.TEXT, universalIdentifier: '3e14e30f-8271-5a13-9c1f-ce5edb932325', }, - { - defaultValue: 'now', - description: 'Creation date', - icon: 'IconCalendar', - isNullable: false, - label: 'Creation date', - name: 'createdAt', - type: FieldMetadataType.DATE_TIME, - universalIdentifier: 'b60eaf02-b008-5ebc-8fe5-123f8e92cc9e', - }, - { - defaultValue: 'now', - description: 'Last time the record was changed', - icon: 'IconCalendarClock', - isNullable: false, - label: 'Last update', - name: 'updatedAt', - type: FieldMetadataType.DATE_TIME, - universalIdentifier: 'd76f15e3-e877-5b62-af97-9dac7c71065b', - }, - { - defaultValue: null, - description: 'Deletion date', - icon: 'IconCalendarClock', - isNullable: true, - label: 'Deleted at', - name: 'deletedAt', - type: FieldMetadataType.DATE_TIME, - universalIdentifier: 'a5ec9b5d-90ed-587e-8a9b-cd9d1ba43116', - }, - { - defaultValue: { - name: "''", - source: "'MANUAL'", - }, - description: 'The creator of the record', - icon: 'IconCreativeCommonsSa', - isNullable: false, - label: 'Created by', - name: 'createdBy', - type: FieldMetadataType.ACTOR, - universalIdentifier: '4c72ab36-ca4a-5870-b8cc-652385020c45', - }, - { - defaultValue: { - name: "''", - source: "'MANUAL'", - }, - description: 'The workspace member who last updated the record', - icon: 'IconUserCircle', - isNullable: false, - label: 'Updated by', - name: 'updatedBy', - type: FieldMetadataType.ACTOR, - universalIdentifier: '2f481c85-025c-51cf-bccd-3375d252bf26', - }, - { - defaultValue: 0, - description: 'Position', - icon: 'IconHierarchy2', - isNullable: false, - label: 'Position', - name: 'position', - type: FieldMetadataType.POSITION, - universalIdentifier: 'e1178a62-d1a5-5e9c-b730-9118c640711d', - }, - { - defaultValue: null, - description: 'Search vector', - icon: 'IconSearch', - isNullable: true, - label: 'Search vector', - name: 'searchVector', - type: FieldMetadataType.TS_VECTOR, - universalIdentifier: 'e8569449-73b0-5f19-abc0-f6ad06c40341', - }, { description: 'Post cards tied to the PostCard', icon: 'IconBuildingSkyscraper', @@ -1373,16 +1115,6 @@ export const EXPECTED_MANIFEST: Manifest = { type: FieldType.ADDRESS, universalIdentifier: 'd3a2b3c4-5e6f-4a7b-8c9d-0e1f2a3b4c5d', }, - { - defaultValue: 'uuid', - description: 'Id', - icon: 'Icon123', - isNullable: false, - label: 'Id', - name: 'id', - type: FieldMetadataType.UUID, - universalIdentifier: '6c8d489d-f871-598c-92b7-929b5883ae0f', - }, { defaultValue: null, description: 'Name', @@ -1393,82 +1125,6 @@ export const EXPECTED_MANIFEST: Manifest = { type: FieldMetadataType.TEXT, universalIdentifier: 'c400ccb8-cd91-5ae1-81e3-2df8b6f2cf53', }, - { - defaultValue: 'now', - description: 'Creation date', - icon: 'IconCalendar', - isNullable: false, - label: 'Creation date', - name: 'createdAt', - type: FieldMetadataType.DATE_TIME, - universalIdentifier: '88b7a0a5-f2d2-5dce-89a6-e0f27577dd3f', - }, - { - defaultValue: 'now', - description: 'Last time the record was changed', - icon: 'IconCalendarClock', - isNullable: false, - label: 'Last update', - name: 'updatedAt', - type: FieldMetadataType.DATE_TIME, - universalIdentifier: '411c5a73-100a-5255-a54d-de20a5c2ae54', - }, - { - defaultValue: null, - description: 'Deletion date', - icon: 'IconCalendarClock', - isNullable: true, - label: 'Deleted at', - name: 'deletedAt', - type: FieldMetadataType.DATE_TIME, - universalIdentifier: 'd3d59a1e-2873-594a-a7de-630cde6980a9', - }, - { - defaultValue: { - name: "''", - source: "'MANUAL'", - }, - description: 'The creator of the record', - icon: 'IconCreativeCommonsSa', - isNullable: false, - label: 'Created by', - name: 'createdBy', - type: FieldMetadataType.ACTOR, - universalIdentifier: 'f358b82e-1254-581e-8629-4fc4302fdbdf', - }, - { - defaultValue: { - name: "''", - source: "'MANUAL'", - }, - description: 'The workspace member who last updated the record', - icon: 'IconUserCircle', - isNullable: false, - label: 'Updated by', - name: 'updatedBy', - type: FieldMetadataType.ACTOR, - universalIdentifier: '8186fca8-89da-5542-99fe-f0ddf90e1fc1', - }, - { - defaultValue: 0, - description: 'Position', - icon: 'IconHierarchy2', - isNullable: false, - label: 'Position', - name: 'position', - type: FieldMetadataType.POSITION, - universalIdentifier: '9a81ba2c-e4b2-56f8-a610-015decebd3aa', - }, - { - defaultValue: null, - description: 'Search vector', - icon: 'IconSearch', - isNullable: true, - label: 'Search vector', - name: 'searchVector', - type: FieldMetadataType.TS_VECTOR, - universalIdentifier: '41eed58f-1016-5483-b3ff-979f270dd9a6', - }, { description: 'Recipients tied to the Recipient', icon: 'IconBuildingSkyscraper', diff --git a/packages/twenty-sdk/src/cli/utilities/build/common/plugins/__tests__/__snapshots__/stub-twenty-sdk-define.plugin.spec.ts.snap b/packages/twenty-sdk/src/cli/utilities/build/common/plugins/__tests__/__snapshots__/stub-twenty-sdk-define.plugin.spec.ts.snap index 7963f2352d..ac1d691261 100644 --- a/packages/twenty-sdk/src/cli/utilities/build/common/plugins/__tests__/__snapshots__/stub-twenty-sdk-define.plugin.spec.ts.snap +++ b/packages/twenty-sdk/src/cli/utilities/build/common/plugins/__tests__/__snapshots__/stub-twenty-sdk-define.plugin.spec.ts.snap @@ -37,7 +37,6 @@ exports[`stub-twenty-sdk-define plugin > matches the recorded export partition 1 "everyEquals", "favoriteRecordIds", "featureFlags", - "generateDefaultFieldUniversalIdentifier", "hasAnySoftDeleteFilterOnView", "includes", "includesEvery", diff --git a/packages/twenty-sdk/src/cli/utilities/build/manifest/utils/__tests__/get-default-fields-in-object-fields.spec.ts b/packages/twenty-sdk/src/cli/utilities/build/manifest/utils/__tests__/get-default-fields-in-object-fields.spec.ts deleted file mode 100644 index fbeb8da772..0000000000 --- a/packages/twenty-sdk/src/cli/utilities/build/manifest/utils/__tests__/get-default-fields-in-object-fields.spec.ts +++ /dev/null @@ -1,202 +0,0 @@ -import { FieldMetadataType } from 'twenty-shared/types'; -import { getDefaultFieldsInObjectFields } from '@/cli/utilities/build/manifest/utils/get-default-fields-in-object-fields'; -import { getDefaultObjectFields } from '@/cli/utilities/build/manifest/utils/get-default-object-fields'; -import type { ObjectConfig } from '@/sdk/define/objects/object-config'; -import { getDefaultRelationObjectFields } from '@/cli/utilities/build/manifest/utils/get-default-relation-object-fields'; -import { type ObjectFieldManifest } from 'twenty-shared/application'; - -const baseObjectConfig: ObjectConfig = { - universalIdentifier: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', - nameSingular: 'testObject', - namePlural: 'testObjects', - labelSingular: 'Test Object', - labelPlural: 'Test Objects', - fields: [], -}; - -const applicationUniversalIdentifier = 'b7f8a9c0-1d2e-4f40-8a6b-7c8d9e0f1a2b'; - -const getMockDefaultFieldsInObjectFields = (objectConfig: ObjectConfig) => - getDefaultFieldsInObjectFields({ - objectConfig, - applicationUniversalIdentifier, - }); - -const getMockDefaultObjectFields = (objectConfig: ObjectConfig) => - getDefaultObjectFields({ objectConfig, applicationUniversalIdentifier }); - -const getMockDefaultRelationObjectFields = (objectConfig: ObjectConfig) => - getDefaultRelationObjectFields({ - objectConfig, - applicationUniversalIdentifier, - }); - -describe('getDefaultFieldsInObjectFields', () => { - it('should return all default fields when objectConfig has no fields', () => { - const { objectFields, fields } = - getMockDefaultFieldsInObjectFields(baseObjectConfig); - const defaultFields = getMockDefaultObjectFields(baseObjectConfig); - const { - objectFields: defaultRelationObjectFields, - fields: expectedReverseFields, - } = getMockDefaultRelationObjectFields(baseObjectConfig); - - expect(objectFields).toEqual([ - ...defaultFields, - ...defaultRelationObjectFields, - ]); - expect(fields).toEqual(expectedReverseFields); - - for (const field of fields) { - if (field.type === FieldMetadataType.RELATION) { - expect(field.isNullable).toBe(true); - } - } - - for (const objectField of objectFields) { - if (objectField.type === FieldMetadataType.RELATION) { - expect(objectField.isNullable).toBe(true); - } - } - }); - - it('should preserve custom fields and append missing default fields', () => { - const customField: ObjectFieldManifest = { - universalIdentifier: '11111111-1111-1111-1111-111111111111', - name: 'customField', - label: 'Custom Field', - type: FieldMetadataType.TEXT, - }; - - const objectConfig: ObjectConfig = { - ...baseObjectConfig, - fields: [customField], - }; - - const { objectFields } = getMockDefaultFieldsInObjectFields(objectConfig); - const defaultFields = getMockDefaultObjectFields(objectConfig); - const { objectFields: defaultRelationObjectFields } = - getMockDefaultRelationObjectFields(objectConfig); - - expect(objectFields[0]).toEqual(customField); - expect(objectFields).toHaveLength( - 1 + defaultFields.length + defaultRelationObjectFields.length, - ); - }); - - it('should not inject a default field when a field with the same name exists', () => { - const customIdField: ObjectFieldManifest = { - universalIdentifier: '22222222-2222-2222-2222-222222222222', - name: 'id', - label: 'Custom Id', - type: FieldMetadataType.TEXT, - }; - - const objectConfig: ObjectConfig = { - ...baseObjectConfig, - fields: [customIdField], - }; - - const { objectFields } = getMockDefaultFieldsInObjectFields(objectConfig); - - const idFields = objectFields.filter((f) => f.name === 'id'); - - expect(idFields).toHaveLength(1); - expect(idFields[0]).toEqual(customIdField); - }); - - it('should skip multiple default fields when overridden by custom fields', () => { - const customFields: ObjectFieldManifest[] = [ - { - universalIdentifier: '33333333-3333-3333-3333-333333333333', - name: 'id', - label: 'Custom Id', - type: FieldMetadataType.TEXT, - }, - { - universalIdentifier: '44444444-4444-4444-4444-444444444444', - name: 'name', - label: 'Custom Name', - type: FieldMetadataType.TEXT, - }, - { - universalIdentifier: '55555555-5555-5555-5555-555555555555', - name: 'position', - label: 'Custom Position', - type: FieldMetadataType.NUMBER, - }, - ]; - - const objectConfig: ObjectConfig = { - ...baseObjectConfig, - fields: customFields, - }; - - const { objectFields } = getMockDefaultFieldsInObjectFields(objectConfig); - const defaultFields = getMockDefaultObjectFields(objectConfig); - const { objectFields: defaultRelationObjectFields } = - getMockDefaultRelationObjectFields(objectConfig); - const overriddenCount = defaultFields.filter((df) => - customFields.some((cf) => cf.name === df.name), - ).length; - - expect(objectFields).toHaveLength( - customFields.length + - defaultFields.length + - defaultRelationObjectFields.length - - overriddenCount, - ); - - expect(objectFields.filter((f) => f.name === 'id')).toHaveLength(1); - expect(objectFields.filter((f) => f.name === 'name')).toHaveLength(1); - expect(objectFields.filter((f) => f.name === 'position')).toHaveLength(1); - }); - - it('should place custom fields before default fields in the result', () => { - const customField: ObjectFieldManifest = { - universalIdentifier: '66666666-6666-6666-6666-666666666666', - name: 'customField', - label: 'Custom Field', - type: FieldMetadataType.TEXT, - }; - - const objectConfig: ObjectConfig = { - ...baseObjectConfig, - fields: [customField], - }; - - const { objectFields } = getMockDefaultFieldsInObjectFields(objectConfig); - - expect(objectFields[0]).toEqual(customField); - }); - - it('should not mutate the original objectConfig fields array', () => { - const customField: ObjectFieldManifest = { - universalIdentifier: '77777777-7777-7777-7777-777777777777', - name: 'customField', - label: 'Custom Field', - type: FieldMetadataType.TEXT, - }; - - const objectConfig: ObjectConfig = { - ...baseObjectConfig, - fields: [customField], - }; - - const originalLength = objectConfig.fields.length; - - getMockDefaultFieldsInObjectFields(objectConfig); - - expect(objectConfig.fields).toHaveLength(originalLength); - }); - - it('should return reverse relation fields for each default relation', () => { - const { fields } = getMockDefaultFieldsInObjectFields(baseObjectConfig); - - expect(fields).toHaveLength(4); - - const fieldNames = fields.map((f) => f.name); - - expect(fieldNames).toContain('targetTestObject'); - }); -}); diff --git a/packages/twenty-sdk/src/cli/utilities/build/manifest/utils/__tests__/get-default-object-fields.spec.ts b/packages/twenty-sdk/src/cli/utilities/build/manifest/utils/__tests__/get-default-object-fields.spec.ts deleted file mode 100644 index b597a5789b..0000000000 --- a/packages/twenty-sdk/src/cli/utilities/build/manifest/utils/__tests__/get-default-object-fields.spec.ts +++ /dev/null @@ -1,224 +0,0 @@ -import { FieldMetadataType } from 'twenty-shared/types'; -import { getDefaultObjectFields } from '@/cli/utilities/build/manifest/utils/get-default-object-fields'; -import { generateDefaultFieldUniversalIdentifier } from '@/sdk/define/objects/generate-default-field-universal-identifier'; -import type { ObjectConfig } from '@/sdk/define/objects/object-config'; - -const mockObjectConfig: ObjectConfig = { - universalIdentifier: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', - nameSingular: 'testObject', - namePlural: 'testObjects', - labelSingular: 'Test Object', - labelPlural: 'Test Objects', - fields: [], -}; - -const mockApplicationUniversalIdentifier = - '3d05dbc0-4bd8-4041-a944-8cd0e26c2be1'; - -const getMockDefaultObjectFields = (objectConfig: ObjectConfig) => - getDefaultObjectFields({ - objectConfig, - applicationUniversalIdentifier: mockApplicationUniversalIdentifier, - }); - -const expectedUniversalId = (fieldName: string) => - generateDefaultFieldUniversalIdentifier({ - applicationUniversalIdentifier: mockApplicationUniversalIdentifier, - objectUniversalIdentifier: mockObjectConfig.universalIdentifier, - fieldName, - }); - -describe('getDefaultObjectFields', () => { - it('should return an array of 9 default fields', () => { - const fields = getMockDefaultObjectFields(mockObjectConfig); - - expect(fields).toHaveLength(9); - }); - - it('should include an id field with UUID type', () => { - const fields = getMockDefaultObjectFields(mockObjectConfig); - const idField = fields.find((field) => field.name === 'id'); - - expect(idField).toBeDefined(); - expect(idField).toEqual({ - name: 'id', - label: 'Id', - description: 'Id', - icon: 'Icon123', - isNullable: false, - defaultValue: 'uuid', - type: FieldMetadataType.UUID, - universalIdentifier: expectedUniversalId('id'), - }); - }); - - it('should include a name field with TEXT type', () => { - const fields = getMockDefaultObjectFields(mockObjectConfig); - const nameField = fields.find((field) => field.name === 'name'); - - expect(nameField).toBeDefined(); - expect(nameField).toEqual({ - name: 'name', - label: 'Name', - description: 'Name', - icon: 'IconAbc', - isNullable: true, - defaultValue: null, - type: FieldMetadataType.TEXT, - universalIdentifier: expectedUniversalId('name'), - }); - }); - - it('should include createdAt, updatedAt and deletedAt fields with DATE_TIME type', () => { - const fields = getMockDefaultObjectFields(mockObjectConfig); - const createdAtField = fields.find((field) => field.name === 'createdAt'); - const updatedAtField = fields.find((field) => field.name === 'updatedAt'); - const deletedAtField = fields.find((field) => field.name === 'deletedAt'); - - expect(createdAtField).toBeDefined(); - expect(createdAtField).toEqual({ - name: 'createdAt', - label: 'Creation date', - description: 'Creation date', - icon: 'IconCalendar', - isNullable: false, - defaultValue: 'now', - type: FieldMetadataType.DATE_TIME, - universalIdentifier: expectedUniversalId('createdAt'), - }); - - expect(updatedAtField).toBeDefined(); - expect(updatedAtField).toEqual({ - name: 'updatedAt', - label: 'Last update', - description: 'Last time the record was changed', - icon: 'IconCalendarClock', - isNullable: false, - defaultValue: 'now', - type: FieldMetadataType.DATE_TIME, - universalIdentifier: expectedUniversalId('updatedAt'), - }); - - expect(deletedAtField).toBeDefined(); - expect(deletedAtField).toEqual({ - name: 'deletedAt', - label: 'Deleted at', - description: 'Deletion date', - icon: 'IconCalendarClock', - isNullable: true, - defaultValue: null, - type: FieldMetadataType.DATE_TIME, - universalIdentifier: expectedUniversalId('deletedAt'), - }); - }); - - it('should include createdBy and updatedBy fields with ACTOR type', () => { - const fields = getMockDefaultObjectFields(mockObjectConfig); - const createdByField = fields.find((field) => field.name === 'createdBy'); - const updatedByField = fields.find((field) => field.name === 'updatedBy'); - - expect(createdByField).toBeDefined(); - expect(createdByField).toEqual({ - name: 'createdBy', - label: 'Created by', - description: 'The creator of the record', - icon: 'IconCreativeCommonsSa', - isNullable: false, - defaultValue: { name: "''", source: "'MANUAL'" }, - type: FieldMetadataType.ACTOR, - universalIdentifier: expectedUniversalId('createdBy'), - }); - - expect(updatedByField).toBeDefined(); - expect(updatedByField).toEqual({ - name: 'updatedBy', - label: 'Updated by', - description: 'The workspace member who last updated the record', - icon: 'IconUserCircle', - isNullable: false, - defaultValue: { name: "''", source: "'MANUAL'" }, - type: FieldMetadataType.ACTOR, - universalIdentifier: expectedUniversalId('updatedBy'), - }); - }); - - it('should include a position field with POSITION type', () => { - const fields = getMockDefaultObjectFields(mockObjectConfig); - const positionField = fields.find((field) => field.name === 'position'); - - expect(positionField).toBeDefined(); - expect(positionField).toEqual({ - name: 'position', - label: 'Position', - description: 'Position', - icon: 'IconHierarchy2', - isNullable: false, - defaultValue: 0, - type: FieldMetadataType.POSITION, - universalIdentifier: expectedUniversalId('position'), - }); - }); - - it('should include a searchVector field with TS_VECTOR type', () => { - const fields = getMockDefaultObjectFields(mockObjectConfig); - const searchVectorField = fields.find( - (field) => field.name === 'searchVector', - ); - - expect(searchVectorField).toBeDefined(); - expect(searchVectorField).toEqual({ - name: 'searchVector', - label: 'Search vector', - description: 'Search vector', - icon: 'IconSearch', - isNullable: true, - defaultValue: null, - type: FieldMetadataType.TS_VECTOR, - universalIdentifier: expectedUniversalId('searchVector'), - }); - }); - - it('should generate deterministic universalIdentifiers based on objectConfig', () => { - const firstResult = getMockDefaultObjectFields(mockObjectConfig); - const secondResult = getMockDefaultObjectFields(mockObjectConfig); - - firstResult.forEach((field, index) => { - expect(field.universalIdentifier).toBe( - secondResult[index].universalIdentifier, - ); - }); - }); - - it('should generate different universalIdentifiers for different objectConfigs', () => { - const otherObjectConfig: ObjectConfig = { - ...mockObjectConfig, - universalIdentifier: 'ffffffff-ffff-ffff-ffff-ffffffffffff', - }; - - const fieldsA = getMockDefaultObjectFields(mockObjectConfig); - const fieldsB = getMockDefaultObjectFields(otherObjectConfig); - - fieldsA.forEach((fieldA, index) => { - expect(fieldA.universalIdentifier).not.toBe( - fieldsB[index].universalIdentifier, - ); - }); - }); - - it('should return fields in the expected order', () => { - const fields = getMockDefaultObjectFields(mockObjectConfig); - const fieldNames = fields.map((field) => field.name); - - expect(fieldNames).toEqual([ - 'id', - 'name', - 'createdAt', - 'updatedAt', - 'deletedAt', - 'createdBy', - 'updatedBy', - 'position', - 'searchVector', - ]); - }); -}); diff --git a/packages/twenty-sdk/src/cli/utilities/build/manifest/utils/get-default-fields-in-object-fields.ts b/packages/twenty-sdk/src/cli/utilities/build/manifest/utils/get-default-fields-in-object-fields.ts index c18ab1cccc..ae7477a290 100644 --- a/packages/twenty-sdk/src/cli/utilities/build/manifest/utils/get-default-fields-in-object-fields.ts +++ b/packages/twenty-sdk/src/cli/utilities/build/manifest/utils/get-default-fields-in-object-fields.ts @@ -1,10 +1,32 @@ -import type { ObjectConfig } from '@/sdk/define/objects/object-config'; -import { getDefaultObjectFields } from '@/cli/utilities/build/manifest/utils/get-default-object-fields'; import { getDefaultRelationObjectFields } from '@/cli/utilities/build/manifest/utils/get-default-relation-object-fields'; +import type { ObjectConfig } from '@/sdk/define/objects/object-config'; import { type FieldManifest, + getFieldUniversalIdentifier, type ObjectFieldManifest, } from 'twenty-shared/application'; +import { FieldMetadataType } from 'twenty-shared/types'; + +const getDefaultNameObjectField = ({ + objectConfig, + applicationUniversalIdentifier, +}: { + objectConfig: ObjectConfig; + applicationUniversalIdentifier: string; +}): ObjectFieldManifest => ({ + name: 'name', + label: 'Name', + description: 'Name', + icon: 'IconAbc', + isNullable: true, + defaultValue: null, + type: FieldMetadataType.TEXT, + universalIdentifier: getFieldUniversalIdentifier({ + applicationUniversalIdentifier, + objectUniversalIdentifier: objectConfig.universalIdentifier, + name: 'name', + }), +}); export const getDefaultFieldsInObjectFields = ({ objectConfig, @@ -13,24 +35,25 @@ export const getDefaultFieldsInObjectFields = ({ objectConfig: ObjectConfig; applicationUniversalIdentifier: string; }): { objectFields: ObjectFieldManifest[]; fields: FieldManifest[] } => { - const defaultObjectFields = getDefaultObjectFields({ - objectConfig, - applicationUniversalIdentifier, - }); const { objectFields: defaultRelationObjectFields, fields: reverseFields } = getDefaultRelationObjectFields({ objectConfig, applicationUniversalIdentifier, }); - const objectConfigFieldNames = (objectConfig.fields ?? []).map((f) => f.name); + const objectConfigFieldNames = (objectConfig.fields ?? []).map( + (field) => field.name, + ); const objectFieldsWithDefaults = [...objectConfig.fields]; - for (const defaultField of defaultObjectFields) { - if (!objectConfigFieldNames.includes(defaultField.name)) { - objectFieldsWithDefaults.push(defaultField); - } + const defaultNameObjectField = getDefaultNameObjectField({ + objectConfig, + applicationUniversalIdentifier, + }); + + if (!objectConfigFieldNames.includes(defaultNameObjectField.name)) { + objectFieldsWithDefaults.push(defaultNameObjectField); } for (const defaultRelationField of defaultRelationObjectFields) { diff --git a/packages/twenty-sdk/src/cli/utilities/build/manifest/utils/get-default-object-fields.ts b/packages/twenty-sdk/src/cli/utilities/build/manifest/utils/get-default-object-fields.ts deleted file mode 100644 index 061657ea54..0000000000 --- a/packages/twenty-sdk/src/cli/utilities/build/manifest/utils/get-default-object-fields.ts +++ /dev/null @@ -1,161 +0,0 @@ -import type { ObjectConfig } from '@/sdk/define/objects/object-config'; -import { FieldMetadataType } from 'twenty-shared/types'; -import { generateDefaultFieldUniversalIdentifier } from '@/sdk/define/objects/generate-default-field-universal-identifier'; -import { type ObjectFieldManifest } from 'twenty-shared/application'; - -export const getDefaultObjectFields = ({ - objectConfig, - applicationUniversalIdentifier, -}: { - objectConfig: ObjectConfig; - applicationUniversalIdentifier: string; -}): ObjectFieldManifest[] => { - const objectUniversalIdentifier = objectConfig.universalIdentifier; - - const idField: ObjectFieldManifest = { - name: 'id', - label: 'Id', - description: 'Id', - icon: 'Icon123', - isNullable: false, - defaultValue: 'uuid', - type: FieldMetadataType.UUID as const, - universalIdentifier: generateDefaultFieldUniversalIdentifier({ - applicationUniversalIdentifier, - objectUniversalIdentifier, - fieldName: 'id', - }), - }; - - const nameField: ObjectFieldManifest = { - name: 'name', - label: 'Name', - description: 'Name', - icon: 'IconAbc', - isNullable: true, - defaultValue: null, - type: FieldMetadataType.TEXT as const, - universalIdentifier: generateDefaultFieldUniversalIdentifier({ - applicationUniversalIdentifier, - objectUniversalIdentifier, - fieldName: 'name', - }), - }; - - const createdAtField: ObjectFieldManifest = { - name: 'createdAt', - label: 'Creation date', - description: 'Creation date', - icon: 'IconCalendar', - isNullable: false, - defaultValue: 'now', - type: FieldMetadataType.DATE_TIME as const, - universalIdentifier: generateDefaultFieldUniversalIdentifier({ - applicationUniversalIdentifier, - objectUniversalIdentifier, - fieldName: 'createdAt', - }), - }; - - const updatedAtField: ObjectFieldManifest = { - name: 'updatedAt', - label: 'Last update', - description: 'Last time the record was changed', - icon: 'IconCalendarClock', - isNullable: false, - defaultValue: 'now', - type: FieldMetadataType.DATE_TIME as const, - universalIdentifier: generateDefaultFieldUniversalIdentifier({ - applicationUniversalIdentifier, - objectUniversalIdentifier, - fieldName: 'updatedAt', - }), - }; - - const deletedAtField: ObjectFieldManifest = { - name: 'deletedAt', - label: 'Deleted at', - description: 'Deletion date', - icon: 'IconCalendarClock', - isNullable: true, - defaultValue: null, - type: FieldMetadataType.DATE_TIME as const, - universalIdentifier: generateDefaultFieldUniversalIdentifier({ - applicationUniversalIdentifier, - objectUniversalIdentifier, - fieldName: 'deletedAt', - }), - }; - - const createdByField: ObjectFieldManifest = { - name: 'createdBy', - label: 'Created by', - description: 'The creator of the record', - icon: 'IconCreativeCommonsSa', - isNullable: false, - defaultValue: { name: "''", source: "'MANUAL'" }, - type: FieldMetadataType.ACTOR as const, - universalIdentifier: generateDefaultFieldUniversalIdentifier({ - applicationUniversalIdentifier, - objectUniversalIdentifier, - fieldName: 'createdBy', - }), - }; - - const updatedByField: ObjectFieldManifest = { - name: 'updatedBy', - label: 'Updated by', - description: 'The workspace member who last updated the record', - icon: 'IconUserCircle', - isNullable: false, - defaultValue: { name: "''", source: "'MANUAL'" }, - type: FieldMetadataType.ACTOR as const, - universalIdentifier: generateDefaultFieldUniversalIdentifier({ - applicationUniversalIdentifier, - objectUniversalIdentifier, - fieldName: 'updatedBy', - }), - }; - - const positionField: ObjectFieldManifest = { - name: 'position', - label: 'Position', - description: 'Position', - icon: 'IconHierarchy2', - isNullable: false, - defaultValue: 0, - type: FieldMetadataType.POSITION, - universalIdentifier: generateDefaultFieldUniversalIdentifier({ - applicationUniversalIdentifier, - objectUniversalIdentifier, - fieldName: 'position', - }), - }; - - const searchVectorField: ObjectFieldManifest = { - name: 'searchVector', - label: 'Search vector', - icon: 'IconSearch', - description: 'Search vector', - isNullable: true, - defaultValue: null, - type: FieldMetadataType.TS_VECTOR, - universalIdentifier: generateDefaultFieldUniversalIdentifier({ - applicationUniversalIdentifier, - objectUniversalIdentifier, - fieldName: 'searchVector', - }), - }; - - return [ - idField, - nameField, - createdAtField, - updatedAtField, - deletedAtField, - createdByField, - updatedByField, - positionField, - searchVectorField, - ]; -}; diff --git a/packages/twenty-sdk/src/cli/utilities/build/manifest/utils/get-default-relation-object-fields.ts b/packages/twenty-sdk/src/cli/utilities/build/manifest/utils/get-default-relation-object-fields.ts index ccbb0a9f31..8f5a203032 100644 --- a/packages/twenty-sdk/src/cli/utilities/build/manifest/utils/get-default-relation-object-fields.ts +++ b/packages/twenty-sdk/src/cli/utilities/build/manifest/utils/get-default-relation-object-fields.ts @@ -1,8 +1,8 @@ import { RelationType } from '@/sdk/define'; -import { generateDefaultFieldUniversalIdentifier } from '@/sdk/define/objects/generate-default-field-universal-identifier'; import type { ObjectConfig } from '@/sdk/define/objects/object-config'; import { type FieldManifest, + getFieldUniversalIdentifier, type ObjectFieldManifest, } from 'twenty-shared/application'; import { STANDARD_OBJECTS } from 'twenty-shared/metadata'; @@ -132,19 +132,17 @@ export const getDefaultRelationObjectFields = ({ for (const config of DEFAULT_RELATION_CONFIGS) { const standardObject = STANDARD_OBJECTS[config.standardObjectKey]; - const forwardFieldUniversalIdentifier = - generateDefaultFieldUniversalIdentifier({ - applicationUniversalIdentifier, - objectUniversalIdentifier: objectConfig.universalIdentifier, - fieldName: config.fieldName, - }); + const forwardFieldUniversalIdentifier = getFieldUniversalIdentifier({ + applicationUniversalIdentifier, + objectUniversalIdentifier: objectConfig.universalIdentifier, + name: config.fieldName, + }); - const reverseFieldUniversalIdentifier = - generateDefaultFieldUniversalIdentifier({ - applicationUniversalIdentifier, - objectUniversalIdentifier: standardObject.universalIdentifier, - fieldName: config.targetFieldName(objectConfig), - }); + const reverseFieldUniversalIdentifier = getFieldUniversalIdentifier({ + applicationUniversalIdentifier, + objectUniversalIdentifier: standardObject.universalIdentifier, + name: config.targetFieldName(objectConfig), + }); const forwardField: ObjectFieldManifest = { name: config.fieldName, diff --git a/packages/twenty-sdk/src/cli/utilities/entity/__tests__/get-view-base-file.spec.ts b/packages/twenty-sdk/src/cli/utilities/entity/__tests__/get-view-base-file.spec.ts index 70220fc96f..37353bcf2a 100644 --- a/packages/twenty-sdk/src/cli/utilities/entity/__tests__/get-view-base-file.spec.ts +++ b/packages/twenty-sdk/src/cli/utilities/entity/__tests__/get-view-base-file.spec.ts @@ -1,6 +1,7 @@ import { getViewBaseFile } from '@/cli/utilities/entity/entity-view-template'; +import { getFieldUniversalIdentifier } from 'twenty-shared/application'; -const APPLICATION_UNIVERSAL_IDENTIFIER = 'app-abc-123'; +const APPLICATION_UNIVERSAL_IDENTIFIER = 'a1a2a3a4-a5a6-4000-8000-000000000001'; const getTestViewBaseFile = ( overrides: Omit< @@ -127,10 +128,11 @@ describe('getViewBaseFile', () => { expect(matches!.length).toBeGreaterThanOrEqual(2); }); - it('should emit a generateDefaultFieldUniversalIdentifier call for fields using defaultFieldName', () => { + it('should resolve a deterministic universal identifier literal for fields using defaultFieldName', () => { + const objectUniversalIdentifier = 'b1b2b3b4-b5b6-4000-8000-000000000001'; const result = getTestViewBaseFile({ name: 'view-default-field', - objectUniversalIdentifier: 'obj-abc-123', + objectUniversalIdentifier, fields: [ { defaultFieldName: 'createdAt', @@ -139,18 +141,20 @@ describe('getViewBaseFile', () => { ], }); + const expectedFieldUniversalIdentifier = getFieldUniversalIdentifier({ + applicationUniversalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER, + objectUniversalIdentifier, + name: 'createdAt', + }); + + expect(result).toContain("import { defineView } from 'twenty-sdk/define';"); + expect(result).not.toContain('generateDefaultFieldUniversalIdentifier'); expect(result).toContain( - "import {\n defineView,\n generateDefaultFieldUniversalIdentifier,\n} from 'twenty-sdk/define';", + `fieldMetadataUniversalIdentifier: '${expectedFieldUniversalIdentifier}'`, ); - expect(result).toContain( - 'fieldMetadataUniversalIdentifier: generateDefaultFieldUniversalIdentifier({', - ); - expect(result).toContain("applicationUniversalIdentifier: 'app-abc-123'"); - expect(result).toContain("objectUniversalIdentifier: 'obj-abc-123'"); - expect(result).toContain("fieldName: 'createdAt'"); }); - it('should not import generateDefaultFieldUniversalIdentifier when no field uses defaultFieldName', () => { + it('should not import any helper when no field uses defaultFieldName', () => { const result = getTestViewBaseFile({ name: 'view-literal-only', fields: [ diff --git a/packages/twenty-sdk/src/cli/utilities/entity/entity-view-template.ts b/packages/twenty-sdk/src/cli/utilities/entity/entity-view-template.ts index 27ce1073e5..c6173b8d16 100644 --- a/packages/twenty-sdk/src/cli/utilities/entity/entity-view-template.ts +++ b/packages/twenty-sdk/src/cli/utilities/entity/entity-view-template.ts @@ -1,4 +1,5 @@ import { kebabCase } from '@/cli/utilities/string/kebab-case'; +import { getFieldUniversalIdentifier } from 'twenty-shared/application'; import { type ViewType } from 'twenty-shared/types'; import { v4 } from 'uuid'; @@ -29,14 +30,16 @@ const renderFieldEntry = ({ const isVisible = field.isVisible ?? true; const size = field.size ?? 200; - const fieldMetadataUniversalIdentifierLine = + const resolvedFieldMetadataUniversalIdentifier = 'defaultFieldName' in field - ? ` fieldMetadataUniversalIdentifier: generateDefaultFieldUniversalIdentifier({ - applicationUniversalIdentifier: '${applicationUniversalIdentifier}', - objectUniversalIdentifier: '${objectUniversalIdentifier}', - fieldName: '${field.defaultFieldName}', - })` - : ` fieldMetadataUniversalIdentifier: '${field.fieldMetadataUniversalIdentifier}'`; + ? getFieldUniversalIdentifier({ + applicationUniversalIdentifier, + objectUniversalIdentifier, + name: field.defaultFieldName, + }) + : field.fieldMetadataUniversalIdentifier; + + const fieldMetadataUniversalIdentifierLine = ` fieldMetadataUniversalIdentifier: '${resolvedFieldMetadataUniversalIdentifier}'`; return ` { universalIdentifier: '${universalIdentifier}', @@ -64,10 +67,6 @@ export const getViewBaseFile = ({ }) => { const kebabCaseName = kebabCase(name); - const hasDefaultFieldEntry = fields.some( - (field) => 'defaultFieldName' in field, - ); - const defaultFields = ` // fields: [ // { // universalIdentifier: '...', @@ -95,12 +94,7 @@ ${fields const typeBlock = type !== undefined ? ` type: '${type}',\n` : ''; - const imports = hasDefaultFieldEntry - ? `import { - defineView, - generateDefaultFieldUniversalIdentifier, -} from 'twenty-sdk/define';` - : `import { defineView } from 'twenty-sdk/define';`; + const imports = `import { defineView } from 'twenty-sdk/define';`; return `${imports} diff --git a/packages/twenty-sdk/src/sdk/define/index.ts b/packages/twenty-sdk/src/sdk/define/index.ts index 8fec66ad08..a75a2a6061 100644 --- a/packages/twenty-sdk/src/sdk/define/index.ts +++ b/packages/twenty-sdk/src/sdk/define/index.ts @@ -109,7 +109,6 @@ export { defineConnectionProvider } from '@/sdk/define/connection-providers/defi export { defineNavigationMenuItem } from '@/sdk/define/navigation-menu-items/define-navigation-menu-item'; export { defineObject } from '@/sdk/define/objects/define-object'; -export { generateDefaultFieldUniversalIdentifier } from '@/sdk/define/objects/generate-default-field-universal-identifier'; export { STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS as STANDARD_OBJECT, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, diff --git a/packages/twenty-sdk/src/sdk/define/objects/__tests__/generate-default-field-universal-identifier.spec.ts b/packages/twenty-sdk/src/sdk/define/objects/__tests__/generate-default-field-universal-identifier.spec.ts deleted file mode 100644 index 21cf69711e..0000000000 --- a/packages/twenty-sdk/src/sdk/define/objects/__tests__/generate-default-field-universal-identifier.spec.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { generateDefaultFieldUniversalIdentifier } from '@/sdk/define/objects/generate-default-field-universal-identifier'; - -import { getFieldUniversalIdentifier } from 'twenty-shared/application'; - -describe('generateDefaultFieldUniversalIdentifier', () => { - const applicationUniversalIdentifier = 'c6061e2c-7b5c-4a63-b6a4-6f2ef2f2fefb'; - const objectUniversalIdentifier = '55b79f88-4094-4b3f-a0ac-1a91a55714f2'; - - it('should generate a unique universal identifier', () => { - const uId1 = generateDefaultFieldUniversalIdentifier({ - applicationUniversalIdentifier, - objectUniversalIdentifier, - fieldName: 'id', - }); - const uId2 = generateDefaultFieldUniversalIdentifier({ - applicationUniversalIdentifier, - objectUniversalIdentifier, - fieldName: 'id', - }); - - const anotherUId = generateDefaultFieldUniversalIdentifier({ - applicationUniversalIdentifier, - objectUniversalIdentifier, - fieldName: 'name', - }); - - expect(uId1).toEqual(uId2); - expect(uId1).not.toEqual(anotherUId); - }); - - it('should match the shared getFieldUniversalIdentifier derivation', () => { - const universalIdentifier = generateDefaultFieldUniversalIdentifier({ - applicationUniversalIdentifier, - objectUniversalIdentifier, - fieldName: 'createdAt', - }); - - expect(universalIdentifier).toEqual( - getFieldUniversalIdentifier({ - applicationUniversalIdentifier, - objectUniversalIdentifier, - name: 'createdAt', - }), - ); - }); - - it('should generate different identifiers for different applications', () => { - const otherApplicationUId = generateDefaultFieldUniversalIdentifier({ - applicationUniversalIdentifier: '0b04e15c-27b2-4741-9046-b32e07469072', - objectUniversalIdentifier, - fieldName: 'id', - }); - - expect(otherApplicationUId).not.toEqual( - generateDefaultFieldUniversalIdentifier({ - applicationUniversalIdentifier, - objectUniversalIdentifier, - fieldName: 'id', - }), - ); - }); -}); diff --git a/packages/twenty-sdk/src/sdk/define/objects/generate-default-field-universal-identifier.ts b/packages/twenty-sdk/src/sdk/define/objects/generate-default-field-universal-identifier.ts deleted file mode 100644 index 8892edb5c4..0000000000 --- a/packages/twenty-sdk/src/sdk/define/objects/generate-default-field-universal-identifier.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { getFieldUniversalIdentifier } from 'twenty-shared/application'; - -export const generateDefaultFieldUniversalIdentifier = ({ - applicationUniversalIdentifier, - objectUniversalIdentifier, - fieldName, -}: { - applicationUniversalIdentifier: string; - objectUniversalIdentifier: string; - fieldName: string; -}) => - getFieldUniversalIdentifier({ - applicationUniversalIdentifier, - objectUniversalIdentifier, - name: fieldName, - }); diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/2-16/utils/__tests__/build-search-field-metadata-backfill-operations.util.spec.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/2-16/utils/__tests__/build-search-field-metadata-backfill-operations.util.spec.ts index eaac4d78db..4955883268 100644 --- a/packages/twenty-server/src/database/commands/upgrade-version-command/2-16/utils/__tests__/build-search-field-metadata-backfill-operations.util.spec.ts +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/2-16/utils/__tests__/build-search-field-metadata-backfill-operations.util.spec.ts @@ -5,15 +5,16 @@ import { buildSearchFieldMetadataBackfillOperations } from 'src/database/command import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type'; import { getFlatFieldMetadataMock } from 'src/engine/metadata-modules/flat-field-metadata/__mocks__/get-flat-field-metadata.mock'; import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type'; -import { SEARCH_VECTOR_FIELD } from 'src/engine/metadata-modules/search-field-metadata/constants/search-vector-field.constants'; import { getFlatObjectMetadataMock, getStandardFlatObjectMetadataMock, } from 'src/engine/metadata-modules/flat-object-metadata/__mocks__/get-flat-object-metadata.mock'; import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type'; import { type FlatSearchFieldMetadata } from 'src/engine/metadata-modules/flat-search-field-metadata/types/flat-search-field-metadata.type'; +import { SEARCH_VECTOR_FIELD } from 'src/engine/metadata-modules/search-field-metadata/constants/search-vector-field.constants'; -const CUSTOM_APPLICATION_UNIVERSAL_IDENTIFIER = 'custom-application-uid'; +const CUSTOM_APPLICATION_UNIVERSAL_IDENTIFIER = + 'c0c1c2c3-c4c5-4000-8000-000000000001'; const CUSTOM_APPLICATION_ID = 'custom-application-id'; const buildUniversalIdentifiersByApplicationId = ( @@ -129,6 +130,7 @@ const buildSearchFieldMetadata = ({ applicationId: 'unused-application-id', applicationUniversalIdentifier, position, + isSystemSideEffect: true, workspaceId: 'workspace-id', createdAt, updatedAt: createdAt, @@ -221,8 +223,9 @@ describe('buildSearchFieldMetadataBackfillOperations', () => { searchVectorField, ]), flatSearchFieldMetadataMaps: buildFlatSearchFieldMetadataMaps([]), - standardFlatSearchFieldMetadataMaps: - buildFlatSearchFieldMetadataMaps([]), + standardFlatSearchFieldMetadataMaps: buildFlatSearchFieldMetadataMaps( + [], + ), customApplicationId: CUSTOM_APPLICATION_ID, }); @@ -288,8 +291,9 @@ describe('buildSearchFieldMetadataBackfillOperations', () => { flatObjectMetadataMaps: buildFlatObjectMetadataMaps([junctionObject]), flatFieldMetadataMaps: buildFlatFieldMetadataMaps([idField]), flatSearchFieldMetadataMaps: buildFlatSearchFieldMetadataMaps([]), - standardFlatSearchFieldMetadataMaps: - buildFlatSearchFieldMetadataMaps([]), + standardFlatSearchFieldMetadataMaps: buildFlatSearchFieldMetadataMaps( + [], + ), customApplicationId: CUSTOM_APPLICATION_ID, }); @@ -592,8 +596,7 @@ describe('buildSearchFieldMetadataBackfillOperations', () => { fieldMetadataId: nameField.id, objectMetadataUniversalIdentifier: customObject.universalIdentifier, fieldMetadataUniversalIdentifier: nameField.universalIdentifier, - applicationUniversalIdentifier: - CUSTOM_APPLICATION_UNIVERSAL_IDENTIFIER, + applicationUniversalIdentifier: CUSTOM_APPLICATION_UNIVERSAL_IDENTIFIER, }); const { flatSearchFieldMetadatasToCreateByApplicationUniversalIdentifier } = @@ -606,8 +609,9 @@ describe('buildSearchFieldMetadataBackfillOperations', () => { flatSearchFieldMetadataMaps: buildFlatSearchFieldMetadataMaps([ existingSearchFieldMetadata, ]), - standardFlatSearchFieldMetadataMaps: - buildFlatSearchFieldMetadataMaps([]), + standardFlatSearchFieldMetadataMaps: buildFlatSearchFieldMetadataMaps( + [], + ), customApplicationId: CUSTOM_APPLICATION_ID, }); @@ -650,8 +654,9 @@ describe('buildSearchFieldMetadataBackfillOperations', () => { flatObjectMetadataMaps: buildFlatObjectMetadataMaps([customObject]), flatFieldMetadataMaps: buildFlatFieldMetadataMaps([relationNameField]), flatSearchFieldMetadataMaps: buildFlatSearchFieldMetadataMaps([]), - standardFlatSearchFieldMetadataMaps: - buildFlatSearchFieldMetadataMaps([]), + standardFlatSearchFieldMetadataMaps: buildFlatSearchFieldMetadataMaps( + [], + ), customApplicationId: CUSTOM_APPLICATION_ID, }); @@ -685,8 +690,9 @@ describe('buildSearchFieldMetadataBackfillOperations', () => { nameDescriptionField, ]), flatSearchFieldMetadataMaps: buildFlatSearchFieldMetadataMaps([]), - standardFlatSearchFieldMetadataMaps: - buildFlatSearchFieldMetadataMaps([]), + standardFlatSearchFieldMetadataMaps: buildFlatSearchFieldMetadataMaps( + [], + ), customApplicationId: CUSTOM_APPLICATION_ID, }); @@ -716,8 +722,9 @@ describe('buildSearchFieldMetadataBackfillOperations', () => { nameDescriptionField, ]), flatSearchFieldMetadataMaps: buildFlatSearchFieldMetadataMaps([]), - standardFlatSearchFieldMetadataMaps: - buildFlatSearchFieldMetadataMaps([]), + standardFlatSearchFieldMetadataMaps: buildFlatSearchFieldMetadataMaps( + [], + ), customApplicationId: CUSTOM_APPLICATION_ID, }); diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/2-20/2-20-instance-command-fast-1783580127637-add-is-system-side-effect-to-search-field-metadata.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/2-20/2-20-instance-command-fast-1783580127637-add-is-system-side-effect-to-search-field-metadata.ts new file mode 100644 index 0000000000..9f90b9090a --- /dev/null +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/2-20/2-20-instance-command-fast-1783580127637-add-is-system-side-effect-to-search-field-metadata.ts @@ -0,0 +1,23 @@ +import { QueryRunner } from 'typeorm'; + +import { RegisteredInstanceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-instance-command.decorator'; +import { FastInstanceCommand } from 'src/engine/core-modules/upgrade/interfaces/fast-instance-command.interface'; + +// searchFieldMetadata rows are always system-derived (never user-authored), so the +// new column defaults to true, which also backfills every existing row correctly. +@RegisteredInstanceCommand('2.20.0', 1783580127637) +export class AddIsSystemSideEffectToSearchFieldMetadataFastInstanceCommand + implements FastInstanceCommand +{ + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + 'ALTER TABLE "core"."searchFieldMetadata" ADD COLUMN IF NOT EXISTS "isSystemSideEffect" boolean NOT NULL DEFAULT true', + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + 'ALTER TABLE "core"."searchFieldMetadata" DROP COLUMN IF EXISTS "isSystemSideEffect"', + ); + } +} diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/2-20/2-20-instance-command-slow-1783529458168-backfill-name-field-is-system-side-effect.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/2-20/2-20-instance-command-slow-1783529458168-backfill-name-field-is-system-side-effect.ts new file mode 100644 index 0000000000..133994bd81 --- /dev/null +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/2-20/2-20-instance-command-slow-1783529458168-backfill-name-field-is-system-side-effect.ts @@ -0,0 +1,38 @@ +import { DataSource, QueryRunner } from 'typeorm'; + +import { RegisteredInstanceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-instance-command.decorator'; +import { SlowInstanceCommand } from 'src/engine/core-modules/upgrade/interfaces/slow-instance-command.interface'; + +// The default `name` field used to be provisioned with isSystemSideEffect: true +// (2.15 → 2.19). It is now a caller-provided default (isSystemSideEffect: false), +// like every other user-owned field, so existing rows must be re-flagged. +// +// Scoping by name alone is safe: no engine-owned field is named `name` (the +// reserved set is id/createdAt/updatedAt/deletedAt/createdBy/updatedBy/position/ +// searchVector), and API-created custom fields are always false already. +// +// This is a pure data backfill, so the write lives in runDataMigration() (slow +// instance command) rather than up(): keeping the bulk UPDATE out of the fast +// schema transaction avoids holding an ACCESS EXCLUSIVE lock that could stall +// reads during the deploy. Slow instance commands still run before every +// workspace command of the version (order is fast → slow → workspace), so the +// fresh value is in place before the 2.20 search reconcile workspace commands +// flush and recompute the fieldMetadata flat-entity cache (the UPDATE itself +// does not invalidate the per-workspace cache). +@RegisteredInstanceCommand('2.20.0', 1783529458168, { type: 'slow' }) +export class BackfillNameFieldIsSystemSideEffectSlowInstanceCommand implements SlowInstanceCommand { + async runDataMigration(dataSource: DataSource): Promise { + await dataSource.query( + `UPDATE "core"."fieldMetadata" SET "isSystemSideEffect" = false WHERE "name" = 'name' AND "isSystemSideEffect" = true`, + ); + } + + public async up(_queryRunner: QueryRunner): Promise { + return; + } + + // Intentional no-op: this backfill cannot be safely reversed. Pre-2.15 `name` + public async down(_queryRunner: QueryRunner): Promise { + return; + } +} diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/2-20/2-20-upgrade-version-command.module.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/2-20/2-20-upgrade-version-command.module.ts index da9a87b326..0fb7b8a86d 100644 --- a/packages/twenty-server/src/database/commands/upgrade-version-command/2-20/2-20-upgrade-version-command.module.ts +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/2-20/2-20-upgrade-version-command.module.ts @@ -1,30 +1,44 @@ import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; import { WorkspaceIteratorModule } from 'src/database/commands/command-runners/workspace-iterator.module'; import { BackfillActorSourceEnumValuesCommand } from 'src/database/commands/upgrade-version-command/2-20/2-20-workspace-command-1783499671542-backfill-actor-source-enum-values.command'; -import { BackfillWorkflowVersionToCoreCommand } from 'src/database/commands/upgrade-version-command/2-20/2-20-workspace-command-1783526282685-backfill-workflow-version-to-core.command'; import { AddMessageCampaignStatFieldsCommand } from 'src/database/commands/upgrade-version-command/2-20/2-20-workspace-command-1783525261000-add-message-campaign-stat-fields.command'; import { CreateMessageListViewCommand } from 'src/database/commands/upgrade-version-command/2-20/2-20-workspace-command-1783525261001-create-message-list-view.command'; +import { BackfillWorkflowVersionToCoreCommand } from 'src/database/commands/upgrade-version-command/2-20/2-20-workspace-command-1783526282685-backfill-workflow-version-to-core.command'; +import { ReconcileSearchVectorGinIndexUniversalIdentifierCommand } from 'src/database/commands/upgrade-version-command/2-20/2-20-workspace-command-1783529458169-reconcile-search-vector-gin-index-universal-identifier.command'; +import { ReconcileSearchFieldMetadataCommand } from 'src/database/commands/upgrade-version-command/2-20/2-20-workspace-command-1783529458170-reconcile-search-field-metadata.command'; +import { RebuildInstalledAppSearchVectorsCommand } from 'src/database/commands/upgrade-version-command/2-20/2-20-workspace-command-1783529458171-rebuild-installed-app-search-vectors.command'; import { ApplicationModule } from 'src/engine/core-modules/application/application.module'; import { WorkflowVersionCoreModule } from 'src/engine/core-modules/workflow/workflow-version-core.module'; +import { IndexMetadataEntity } from 'src/engine/metadata-modules/index-metadata/index-metadata.entity'; +import { SearchFieldMetadataEntity } from 'src/engine/metadata-modules/search-field-metadata/search-field-metadata.entity'; +import { WorkspaceMetadataVersionModule } from 'src/engine/metadata-modules/workspace-metadata-version/workspace-metadata-version.module'; import { WorkspaceSchemaManagerModule } from 'src/engine/twenty-orm/workspace-schema-manager/workspace-schema-manager.module'; import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module'; +import { WorkspaceMigrationRunnerModule } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/workspace-migration-runner.module'; import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace-migration/workspace-migration.module'; @Module({ imports: [ ApplicationModule, - WorkspaceIteratorModule, - WorkspaceCacheModule, - WorkspaceMigrationModule, - WorkspaceSchemaManagerModule, + TypeOrmModule.forFeature([IndexMetadataEntity, SearchFieldMetadataEntity]), WorkflowVersionCoreModule, + WorkspaceCacheModule, + WorkspaceIteratorModule, + WorkspaceMetadataVersionModule, + WorkspaceMigrationModule, + WorkspaceMigrationRunnerModule, + WorkspaceSchemaManagerModule, ], providers: [ AddMessageCampaignStatFieldsCommand, CreateMessageListViewCommand, BackfillActorSourceEnumValuesCommand, BackfillWorkflowVersionToCoreCommand, + ReconcileSearchVectorGinIndexUniversalIdentifierCommand, + ReconcileSearchFieldMetadataCommand, + RebuildInstalledAppSearchVectorsCommand, ], }) export class V2_20_UpgradeVersionCommandModule {} diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/2-20/2-20-workspace-command-1783529458169-reconcile-search-vector-gin-index-universal-identifier.command.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/2-20/2-20-workspace-command-1783529458169-reconcile-search-vector-gin-index-universal-identifier.command.ts new file mode 100644 index 0000000000..2e907e4af1 --- /dev/null +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/2-20/2-20-workspace-command-1783529458169-reconcile-search-vector-gin-index-universal-identifier.command.ts @@ -0,0 +1,281 @@ +import { InjectRepository } from '@nestjs/typeorm'; + +import { Command } from 'nest-commander'; +import { isDefined } from 'twenty-shared/utils'; +import { Repository } from 'typeorm'; + +import { ActiveOrSuspendedWorkspaceCommandRunner } from 'src/database/commands/command-runners/active-or-suspended-workspace.command-runner'; +import { WorkspaceIteratorService } from 'src/database/commands/command-runners/workspace-iterator.service'; +import { type RunOnWorkspaceArgs } from 'src/database/commands/command-runners/workspace.command-runner'; +import { buildSearchVectorGinIndexBackfillOperations } from 'src/database/commands/upgrade-version-command/2-20/utils/build-search-vector-gin-index-backfill-operations.util'; +import { + buildSearchVectorGinIndexReOwnOperations, + type SearchVectorGinIndexUniversalIdentifierUpdate, +} from 'src/database/commands/upgrade-version-command/2-20/utils/build-search-vector-gin-index-re-own-operations.util'; +import { ApplicationService } from 'src/engine/core-modules/application/application.service'; +import { RegisteredWorkspaceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-workspace-command.decorator'; +import { getMetadataFlatEntityMapsKey } from 'src/engine/metadata-modules/flat-entity/utils/get-metadata-flat-entity-maps-key.util'; +import { getMetadataRelatedMetadataNames } from 'src/engine/metadata-modules/flat-entity/utils/get-metadata-related-metadata-names.util'; +import { getMetadataSerializedRelationNames } from 'src/engine/metadata-modules/flat-entity/utils/get-metadata-serialized-relation-names.util'; +import { IndexMetadataEntity } from 'src/engine/metadata-modules/index-metadata/index-metadata.entity'; +import { WorkspaceMetadataVersionService } from 'src/engine/metadata-modules/workspace-metadata-version/services/workspace-metadata-version.service'; +import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service'; +import { type UniversalFlatIndexMetadata } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-index-metadata.type'; +import { WorkspaceMigrationValidateBuildAndRunService } from 'src/engine/workspace-manager/workspace-migration/services/workspace-migration-validate-build-and-run-service'; + +type SearchVectorGinIndexReconciliationOperations = { + indexUniversalIdentifierUpdates: SearchVectorGinIndexUniversalIdentifierUpdate[]; + flatIndexesToCreateByApplicationUniversalIdentifier: Record< + string, + UniversalFlatIndexMetadata[] + >; +}; + +@RegisteredWorkspaceCommand('2.20.0', 1783529458169) +@Command({ + name: 'upgrade:2-20:reconcile-search-vector-gin-index-universal-identifier', + description: + 'Converge every searchVector GIN index universal identifier to its deterministic derivation (re-own, all applications) and create the missing GIN index for installed-app objects (backfill). Idempotent, re-own runs before backfill.', +}) +export class ReconcileSearchVectorGinIndexUniversalIdentifierCommand extends ActiveOrSuspendedWorkspaceCommandRunner { + constructor( + protected readonly workspaceIteratorService: WorkspaceIteratorService, + private readonly applicationService: ApplicationService, + private readonly workspaceCacheService: WorkspaceCacheService, + private readonly workspaceMetadataVersionService: WorkspaceMetadataVersionService, + private readonly workspaceMigrationValidateBuildAndRunService: WorkspaceMigrationValidateBuildAndRunService, + // eslint-disable-next-line twenty/prefer-workspace-scoped-repository + @InjectRepository(IndexMetadataEntity) + private readonly indexMetadataRepository: Repository, + ) { + super(workspaceIteratorService); + } + + override async runOnWorkspace({ + workspaceId, + options, + }: RunOnWorkspaceArgs): Promise { + const isDryRun = options.dryRun ?? false; + + const { + indexUniversalIdentifierUpdates, + flatIndexesToCreateByApplicationUniversalIdentifier, + } = await this.computeOperations({ workspaceId }); + + const totalIndexesToBackfill = Object.values( + flatIndexesToCreateByApplicationUniversalIdentifier, + ).reduce((total, flatIndexes) => total + flatIndexes.length, 0); + + if ( + indexUniversalIdentifierUpdates.length === 0 && + totalIndexesToBackfill === 0 + ) { + this.logger.log( + `No searchVector GIN index to reconcile for workspace ${workspaceId}`, + ); + + return; + } + + this.logger.log( + `${isDryRun ? '[DRY RUN] ' : ''}Reconciling ${indexUniversalIdentifierUpdates.length} searchVector GIN index universal identifier(s) and creating ${totalIndexesToBackfill} missing index(es) across ${Object.keys(flatIndexesToCreateByApplicationUniversalIdentifier).length} application(s) for workspace ${workspaceId}`, + ); + + if (isDryRun) { + return; + } + + await this.applyOperations({ + workspaceId, + indexUniversalIdentifierUpdates, + flatIndexesToCreateByApplicationUniversalIdentifier, + }); + + this.logger.log( + `Reconciled searchVector GIN indexes for workspace ${workspaceId}`, + ); + } + + private async computeOperations({ + workspaceId, + }: { + workspaceId: string; + }): Promise { + const { + flatObjectMetadataMaps, + flatFieldMetadataMaps, + flatIndexMaps, + flatApplicationMaps, + } = await this.workspaceCacheService.getOrRecompute(workspaceId, [ + 'flatObjectMetadataMaps', + 'flatFieldMetadataMaps', + 'flatIndexMaps', + 'flatApplicationMaps', + ]); + + const { twentyStandardFlatApplication, workspaceCustomFlatApplication } = + await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow( + { workspaceId }, + ); + + const applicationUniversalIdentifierById = new Map( + Object.values(flatApplicationMaps.byId) + .filter(isDefined) + .map((flatApplication) => [ + flatApplication.id, + flatApplication.universalIdentifier, + ]), + ); + + const indexUniversalIdentifierUpdates = + buildSearchVectorGinIndexReOwnOperations({ + flatObjectMetadataMaps, + flatFieldMetadataMaps, + flatIndexMaps, + applicationUniversalIdentifierById, + }); + + const flatIndexesToCreateByApplicationUniversalIdentifier = + buildSearchVectorGinIndexBackfillOperations({ + flatObjectMetadataMaps, + flatFieldMetadataMaps, + flatIndexMaps, + twentyStandardApplicationId: twentyStandardFlatApplication.id, + workspaceCustomApplicationId: workspaceCustomFlatApplication.id, + }); + + return { + indexUniversalIdentifierUpdates, + flatIndexesToCreateByApplicationUniversalIdentifier, + }; + } + + private async applyOperations({ + workspaceId, + indexUniversalIdentifierUpdates, + flatIndexesToCreateByApplicationUniversalIdentifier, + }: { + workspaceId: string; + } & SearchVectorGinIndexReconciliationOperations): Promise { + // Re-own before backfill: a deterministic index created next to a surviving legacy + // row would collide on the unique universal identifier constraint. + await this.applyReOwn({ workspaceId, indexUniversalIdentifierUpdates }); + await this.applyBackfill({ + workspaceId, + flatIndexesToCreateByApplicationUniversalIdentifier, + }); + } + + private async applyReOwn({ + workspaceId, + indexUniversalIdentifierUpdates, + }: { + workspaceId: string; + indexUniversalIdentifierUpdates: SearchVectorGinIndexUniversalIdentifierUpdate[]; + }): Promise { + if (indexUniversalIdentifierUpdates.length === 0) { + return; + } + + try { + await this.indexMetadataRepository.manager.transaction( + async (entityManager) => { + const transactionalIndexMetadataRepository = + entityManager.getRepository(IndexMetadataEntity); + + for (const { + id, + deterministicUniversalIdentifier, + } of indexUniversalIdentifierUpdates) { + await transactionalIndexMetadataRepository.update( + { id, workspaceId }, + { universalIdentifier: deterministicUniversalIdentifier }, + ); + } + }, + ); + } catch (error) { + // Abort before backfill: creating a deterministic index next to a surviving legacy + // row (re-own rolled back) would collide on the unique universal identifier + // constraint. + this.logger.error( + `Failed to re-own ${indexUniversalIdentifierUpdates.length} searchVector GIN index universal identifier(s) for workspace ${workspaceId}, aborting: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + + throw error; + } + + await this.flushIndexCacheAndBumpMetadataVersion(workspaceId); + } + + private async applyBackfill({ + workspaceId, + flatIndexesToCreateByApplicationUniversalIdentifier, + }: { + workspaceId: string; + flatIndexesToCreateByApplicationUniversalIdentifier: Record< + string, + UniversalFlatIndexMetadata[] + >; + }): Promise { + for (const [ + applicationUniversalIdentifier, + flatIndexesToCreate, + ] of Object.entries(flatIndexesToCreateByApplicationUniversalIdentifier)) { + if (flatIndexesToCreate.length === 0) { + continue; + } + + const validateAndBuildResult = + await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration( + { + isSystemBuild: true, + allFlatEntityOperationByMetadataName: { + index: { + flatEntityToCreate: flatIndexesToCreate, + flatEntityToDelete: [], + flatEntityToUpdate: [], + }, + }, + workspaceId, + applicationUniversalIdentifier, + }, + ); + + if (validateAndBuildResult.status === 'fail') { + this.logger.error( + `Failed to create searchVector GIN index(es) for application ${applicationUniversalIdentifier}:\n${JSON.stringify( + validateAndBuildResult, + null, + 2, + )}`, + ); + + throw new Error( + `Failed to create searchVector GIN index(es) for workspace ${workspaceId}`, + ); + } + } + } + + private async flushIndexCacheAndBumpMetadataVersion( + workspaceId: string, + ): Promise { + const indexRelatedMetadataNames = [ + 'index', + ...getMetadataRelatedMetadataNames('index'), + ...getMetadataSerializedRelationNames('index'), + ] as const; + const cacheKeysToFlush = [ + ...new Set(indexRelatedMetadataNames.map(getMetadataFlatEntityMapsKey)), + ]; + + await this.workspaceCacheService.flush(workspaceId, cacheKeysToFlush); + + await this.workspaceMetadataVersionService.incrementMetadataVersion( + workspaceId, + ); + } +} diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/2-20/2-20-workspace-command-1783529458170-reconcile-search-field-metadata.command.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/2-20/2-20-workspace-command-1783529458170-reconcile-search-field-metadata.command.ts new file mode 100644 index 0000000000..f043bf2cbf --- /dev/null +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/2-20/2-20-workspace-command-1783529458170-reconcile-search-field-metadata.command.ts @@ -0,0 +1,293 @@ +import { InjectRepository } from '@nestjs/typeorm'; + +import { Command } from 'nest-commander'; +import { isDefined } from 'twenty-shared/utils'; +import { Repository } from 'typeorm'; + +import { ActiveOrSuspendedWorkspaceCommandRunner } from 'src/database/commands/command-runners/active-or-suspended-workspace.command-runner'; +import { WorkspaceIteratorService } from 'src/database/commands/command-runners/workspace-iterator.service'; +import { type RunOnWorkspaceArgs } from 'src/database/commands/command-runners/workspace.command-runner'; +import { buildSearchFieldMetadataBackfillOperations } from 'src/database/commands/upgrade-version-command/2-20/utils/build-search-field-metadata-backfill-operations.util'; +import { + buildSearchFieldMetadataReOwnOperations, + type SearchFieldMetadataUniversalIdentifierUpdate, +} from 'src/database/commands/upgrade-version-command/2-20/utils/build-search-field-metadata-re-own-operations.util'; +import { ApplicationService } from 'src/engine/core-modules/application/application.service'; +import { RegisteredWorkspaceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-workspace-command.decorator'; +import { getMetadataFlatEntityMapsKey } from 'src/engine/metadata-modules/flat-entity/utils/get-metadata-flat-entity-maps-key.util'; +import { getMetadataRelatedMetadataNames } from 'src/engine/metadata-modules/flat-entity/utils/get-metadata-related-metadata-names.util'; +import { getMetadataSerializedRelationNames } from 'src/engine/metadata-modules/flat-entity/utils/get-metadata-serialized-relation-names.util'; +import { SearchFieldMetadataEntity } from 'src/engine/metadata-modules/search-field-metadata/search-field-metadata.entity'; +import { WorkspaceMetadataVersionService } from 'src/engine/metadata-modules/workspace-metadata-version/services/workspace-metadata-version.service'; +import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service'; +import { type UniversalFlatSearchFieldMetadata } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-search-field-metadata.type'; +import { WorkspaceMigrationValidateBuildAndRunService } from 'src/engine/workspace-manager/workspace-migration/services/workspace-migration-validate-build-and-run-service'; + +type SearchFieldMetadataReconciliationOperations = { + searchFieldMetadataUniversalIdentifierUpdates: SearchFieldMetadataUniversalIdentifierUpdate[]; + flatSearchFieldMetadatasToCreateByApplicationUniversalIdentifier: Record< + string, + UniversalFlatSearchFieldMetadata[] + >; +}; + +@RegisteredWorkspaceCommand('2.20.0', 1783529458170) +@Command({ + name: 'upgrade:2-20:reconcile-search-field-metadata', + description: + 'Converge every searchFieldMetadata universal identifier to its deterministic derivation (re-own, all applications) and create the missing searchFieldMetadata row for installed-app searchable objects (backfill). Idempotent, re-own runs before backfill to avoid a unique-identifier collision.', +}) +export class ReconcileSearchFieldMetadataCommand extends ActiveOrSuspendedWorkspaceCommandRunner { + constructor( + protected readonly workspaceIteratorService: WorkspaceIteratorService, + private readonly applicationService: ApplicationService, + private readonly workspaceCacheService: WorkspaceCacheService, + private readonly workspaceMetadataVersionService: WorkspaceMetadataVersionService, + private readonly workspaceMigrationValidateBuildAndRunService: WorkspaceMigrationValidateBuildAndRunService, + // eslint-disable-next-line twenty/prefer-workspace-scoped-repository + @InjectRepository(SearchFieldMetadataEntity) + private readonly searchFieldMetadataRepository: Repository, + ) { + super(workspaceIteratorService); + } + + override async runOnWorkspace({ + workspaceId, + options, + }: RunOnWorkspaceArgs): Promise { + const isDryRun = options.dryRun ?? false; + + const { + searchFieldMetadataUniversalIdentifierUpdates, + flatSearchFieldMetadatasToCreateByApplicationUniversalIdentifier, + } = await this.computeOperations({ workspaceId }); + + const totalRowsToBackfill = Object.values( + flatSearchFieldMetadatasToCreateByApplicationUniversalIdentifier, + ).reduce( + (total, flatSearchFieldMetadatas) => + total + flatSearchFieldMetadatas.length, + 0, + ); + + if ( + searchFieldMetadataUniversalIdentifierUpdates.length === 0 && + totalRowsToBackfill === 0 + ) { + this.logger.log( + `No searchFieldMetadata to reconcile for workspace ${workspaceId}`, + ); + + return; + } + + this.logger.log( + `${isDryRun ? '[DRY RUN] ' : ''}Reconciling ${searchFieldMetadataUniversalIdentifierUpdates.length} searchFieldMetadata universal identifier(s) and creating ${totalRowsToBackfill} missing row(s) across ${Object.keys(flatSearchFieldMetadatasToCreateByApplicationUniversalIdentifier).length} application(s) for workspace ${workspaceId}`, + ); + + if (isDryRun) { + return; + } + + await this.applyOperations({ + workspaceId, + searchFieldMetadataUniversalIdentifierUpdates, + flatSearchFieldMetadatasToCreateByApplicationUniversalIdentifier, + }); + + this.logger.log( + `Reconciled searchFieldMetadata for workspace ${workspaceId}`, + ); + } + + private async computeOperations({ + workspaceId, + }: { + workspaceId: string; + }): Promise { + const { + flatObjectMetadataMaps, + flatFieldMetadataMaps, + flatSearchFieldMetadataMaps, + flatApplicationMaps, + } = await this.workspaceCacheService.getOrRecompute(workspaceId, [ + 'flatObjectMetadataMaps', + 'flatFieldMetadataMaps', + 'flatSearchFieldMetadataMaps', + 'flatApplicationMaps', + ]); + + const { twentyStandardFlatApplication, workspaceCustomFlatApplication } = + await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow( + { workspaceId }, + ); + + const applicationUniversalIdentifierById = new Map( + Object.values(flatApplicationMaps.byId) + .filter(isDefined) + .map((flatApplication) => [ + flatApplication.id, + flatApplication.universalIdentifier, + ]), + ); + + const searchFieldMetadataUniversalIdentifierUpdates = + buildSearchFieldMetadataReOwnOperations({ + flatFieldMetadataMaps, + flatSearchFieldMetadataMaps, + applicationUniversalIdentifierById, + }); + + const flatSearchFieldMetadatasToCreateByApplicationUniversalIdentifier = + buildSearchFieldMetadataBackfillOperations({ + flatObjectMetadataMaps, + flatFieldMetadataMaps, + flatSearchFieldMetadataMaps, + applicationUniversalIdentifierById, + twentyStandardApplicationId: twentyStandardFlatApplication.id, + workspaceCustomApplicationId: workspaceCustomFlatApplication.id, + }); + + return { + searchFieldMetadataUniversalIdentifierUpdates, + flatSearchFieldMetadatasToCreateByApplicationUniversalIdentifier, + }; + } + + private async applyOperations({ + workspaceId, + searchFieldMetadataUniversalIdentifierUpdates, + flatSearchFieldMetadatasToCreateByApplicationUniversalIdentifier, + }: { + workspaceId: string; + } & SearchFieldMetadataReconciliationOperations): Promise { + // Re-own before backfill: a deterministic row created next to a surviving legacy v4 + // row (which the deletion-inference short-circuit keeps alive) would collide on the + // unique universal identifier constraint. + await this.applyReOwn({ + workspaceId, + searchFieldMetadataUniversalIdentifierUpdates, + }); + await this.applyBackfill({ + workspaceId, + flatSearchFieldMetadatasToCreateByApplicationUniversalIdentifier, + }); + } + + private async applyReOwn({ + workspaceId, + searchFieldMetadataUniversalIdentifierUpdates, + }: { + workspaceId: string; + searchFieldMetadataUniversalIdentifierUpdates: SearchFieldMetadataUniversalIdentifierUpdate[]; + }): Promise { + if (searchFieldMetadataUniversalIdentifierUpdates.length === 0) { + return; + } + + try { + await this.searchFieldMetadataRepository.manager.transaction( + async (entityManager) => { + const transactionalSearchFieldMetadataRepository = + entityManager.getRepository(SearchFieldMetadataEntity); + + for (const { + id, + deterministicUniversalIdentifier, + } of searchFieldMetadataUniversalIdentifierUpdates) { + await transactionalSearchFieldMetadataRepository.update( + { id, workspaceId }, + { universalIdentifier: deterministicUniversalIdentifier }, + ); + } + }, + ); + } catch (error) { + // Abort before backfill: creating a deterministic row next to a surviving legacy + // row (re-own rolled back) would collide on the unique universal identifier + // constraint. + this.logger.error( + `Failed to re-own ${searchFieldMetadataUniversalIdentifierUpdates.length} searchFieldMetadata universal identifier(s) for workspace ${workspaceId}, aborting: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + + throw error; + } + + await this.flushSearchFieldMetadataCacheAndBumpMetadataVersion(workspaceId); + } + + private async applyBackfill({ + workspaceId, + flatSearchFieldMetadatasToCreateByApplicationUniversalIdentifier, + }: { + workspaceId: string; + flatSearchFieldMetadatasToCreateByApplicationUniversalIdentifier: Record< + string, + UniversalFlatSearchFieldMetadata[] + >; + }): Promise { + for (const [ + applicationUniversalIdentifier, + flatSearchFieldMetadatasToCreate, + ] of Object.entries( + flatSearchFieldMetadatasToCreateByApplicationUniversalIdentifier, + )) { + if (flatSearchFieldMetadatasToCreate.length === 0) { + continue; + } + + const validateAndBuildResult = + await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration( + { + isSystemBuild: true, + allFlatEntityOperationByMetadataName: { + searchFieldMetadata: { + flatEntityToCreate: flatSearchFieldMetadatasToCreate, + flatEntityToDelete: [], + flatEntityToUpdate: [], + }, + }, + workspaceId, + applicationUniversalIdentifier, + }, + ); + + if (validateAndBuildResult.status === 'fail') { + this.logger.error( + `Failed to create searchFieldMetadata row(s) for application ${applicationUniversalIdentifier}:\n${JSON.stringify( + validateAndBuildResult, + null, + 2, + )}`, + ); + + throw new Error( + `Failed to create searchFieldMetadata row(s) for workspace ${workspaceId}`, + ); + } + } + } + + private async flushSearchFieldMetadataCacheAndBumpMetadataVersion( + workspaceId: string, + ): Promise { + const searchFieldMetadataRelatedMetadataNames = [ + 'searchFieldMetadata', + ...getMetadataRelatedMetadataNames('searchFieldMetadata'), + ...getMetadataSerializedRelationNames('searchFieldMetadata'), + ] as const; + const cacheKeysToFlush = [ + ...new Set( + searchFieldMetadataRelatedMetadataNames.map(getMetadataFlatEntityMapsKey), + ), + ]; + + await this.workspaceCacheService.flush(workspaceId, cacheKeysToFlush); + + await this.workspaceMetadataVersionService.incrementMetadataVersion( + workspaceId, + ); + } +} diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/2-20/2-20-workspace-command-1783529458171-rebuild-installed-app-search-vectors.command.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/2-20/2-20-workspace-command-1783529458171-rebuild-installed-app-search-vectors.command.ts new file mode 100644 index 0000000000..e9b74a5aea --- /dev/null +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/2-20/2-20-workspace-command-1783529458171-rebuild-installed-app-search-vectors.command.ts @@ -0,0 +1,99 @@ +import { Command } from 'nest-commander'; +import { FieldMetadataType } from 'twenty-shared/types'; +import { isDefined } from 'twenty-shared/utils'; + +import { ActiveOrSuspendedWorkspaceCommandRunner } from 'src/database/commands/command-runners/active-or-suspended-workspace.command-runner'; +import { WorkspaceIteratorService } from 'src/database/commands/command-runners/workspace-iterator.service'; +import { type RunOnWorkspaceArgs } from 'src/database/commands/command-runners/workspace.command-runner'; +import { ApplicationService } from 'src/engine/core-modules/application/application.service'; +import { RegisteredWorkspaceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-workspace-command.decorator'; +import { TWENTY_STANDARD_APPLICATION } from 'src/engine/workspace-manager/twenty-standard-application/constants/twenty-standard-applications'; +import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service'; +import { type UniversalUpdateFieldAction } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/field/types/workspace-migration-field-action'; +import { WORKSPACE_MIGRATION_ACTION_TYPE } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/constants/workspace-migration-action-type.constant'; +import { WorkspaceMigrationRunnerService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/services/workspace-migration-runner.service'; + +@RegisteredWorkspaceCommand('2.20.0', 1783529458171) +@Command({ + name: 'upgrade:2-20:rebuild-installed-app-search-vectors', + description: + 'Rebuild the searchVector column of every installed-app TS_VECTOR field from its searchFieldMetadata rows, now that the GIN index and searchFieldMetadata rows exist (commands 1 and 2). Idempotent.', +}) +export class RebuildInstalledAppSearchVectorsCommand extends ActiveOrSuspendedWorkspaceCommandRunner { + constructor( + protected readonly workspaceIteratorService: WorkspaceIteratorService, + private readonly applicationService: ApplicationService, + private readonly workspaceCacheService: WorkspaceCacheService, + private readonly workspaceMigrationRunnerService: WorkspaceMigrationRunnerService, + ) { + super(workspaceIteratorService); + } + + override async runOnWorkspace({ + workspaceId, + options, + }: RunOnWorkspaceArgs): Promise { + const isDryRun = options.dryRun ?? false; + + const { flatFieldMetadataMaps } = + await this.workspaceCacheService.getOrRecompute(workspaceId, [ + 'flatFieldMetadataMaps', + ]); + + const { twentyStandardFlatApplication, workspaceCustomFlatApplication } = + await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow( + { workspaceId }, + ); + + const installedAppTsVectorFlatFieldMetadatas = Object.values( + flatFieldMetadataMaps.byUniversalIdentifier, + ) + .filter(isDefined) + .filter( + (flatFieldMetadata) => + flatFieldMetadata.type === FieldMetadataType.TS_VECTOR && + flatFieldMetadata.applicationId !== + twentyStandardFlatApplication.id && + flatFieldMetadata.applicationId !== workspaceCustomFlatApplication.id, + ); + + if (installedAppTsVectorFlatFieldMetadatas.length === 0) { + this.logger.log( + `No installed-app searchVector to rebuild for workspace ${workspaceId}`, + ); + + return; + } + + this.logger.log( + `${isDryRun ? '[DRY RUN] ' : ''}Rebuilding ${installedAppTsVectorFlatFieldMetadatas.length} installed-app searchVector(s) for workspace ${workspaceId}`, + ); + + if (isDryRun) { + return; + } + + const actions: UniversalUpdateFieldAction[] = + installedAppTsVectorFlatFieldMetadatas.map((flatFieldMetadata) => ({ + type: WORKSPACE_MIGRATION_ACTION_TYPE.update, + metadataName: 'fieldMetadata', + universalIdentifier: flatFieldMetadata.universalIdentifier, + update: {}, + rebuildSearchVector: true, + })); + + await this.workspaceMigrationRunnerService.run({ + workspaceMigration: { + // Cross-app actions; this is only the runner's existence gate, not a scope filter. + applicationUniversalIdentifier: + TWENTY_STANDARD_APPLICATION.universalIdentifier, + actions, + }, + workspaceId, + }); + + this.logger.log( + `Rebuilt ${installedAppTsVectorFlatFieldMetadatas.length} installed-app searchVector(s) for workspace ${workspaceId}`, + ); + } +} diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/2-20/add-is-system-side-effect-to-search-field-metadata-upgrade-command-name.constant.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/2-20/add-is-system-side-effect-to-search-field-metadata-upgrade-command-name.constant.ts new file mode 100644 index 0000000000..67ac8a1d32 --- /dev/null +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/2-20/add-is-system-side-effect-to-search-field-metadata-upgrade-command-name.constant.ts @@ -0,0 +1,2 @@ +export const ADD_IS_SYSTEM_SIDE_EFFECT_TO_SEARCH_FIELD_METADATA_UPGRADE_COMMAND_NAME = + '2.20.0_AddIsSystemSideEffectToSearchFieldMetadataFastInstanceCommand_1783580127637'; diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/2-20/utils/__tests__/build-search-field-metadata-backfill-operations.util.spec.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/2-20/utils/__tests__/build-search-field-metadata-backfill-operations.util.spec.ts new file mode 100644 index 0000000000..9a0adb64ae --- /dev/null +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/2-20/utils/__tests__/build-search-field-metadata-backfill-operations.util.spec.ts @@ -0,0 +1,424 @@ +import { + getFieldUniversalIdentifier, + getSearchFieldUniversalIdentifier, +} from 'twenty-shared/application'; +import { FieldMetadataType } from 'twenty-shared/types'; + +import { buildSearchFieldMetadataBackfillOperations } from 'src/database/commands/upgrade-version-command/2-20/utils/build-search-field-metadata-backfill-operations.util'; +import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type'; +import { getFlatFieldMetadataMock } from 'src/engine/metadata-modules/flat-field-metadata/__mocks__/get-flat-field-metadata.mock'; +import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type'; +import { getFlatObjectMetadataMock } from 'src/engine/metadata-modules/flat-object-metadata/__mocks__/get-flat-object-metadata.mock'; +import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type'; +import { type FlatSearchFieldMetadata } from 'src/engine/metadata-modules/flat-search-field-metadata/types/flat-search-field-metadata.type'; +import { SEARCH_VECTOR_FIELD } from 'src/engine/metadata-modules/search-field-metadata/constants/search-vector-field.constants'; + +// Application universal identifiers must be valid UUIDs: they are used as the v5 +// namespace by the deterministic identifier helpers. +const STANDARD_APPLICATION_ID = 'standard-application-id'; +const STANDARD_APPLICATION_UID = '11111111-1111-4111-8111-111111111111'; +const CUSTOM_APPLICATION_ID = 'custom-application-id'; +const CUSTOM_APPLICATION_UID = '22222222-2222-4222-8222-222222222222'; +const INSTALLED_APPLICATION_ID = 'installed-application-id'; +const INSTALLED_APPLICATION_UID = '33333333-3333-4333-8333-333333333333'; + +const applicationUniversalIdentifierById = new Map([ + [STANDARD_APPLICATION_ID, STANDARD_APPLICATION_UID], + [CUSTOM_APPLICATION_ID, CUSTOM_APPLICATION_UID], + [INSTALLED_APPLICATION_ID, INSTALLED_APPLICATION_UID], +]); + +const buildFlatObjectMetadataMaps = ( + flatObjectMetadatas: FlatObjectMetadata[], +): FlatEntityMaps => ({ + byUniversalIdentifier: Object.fromEntries( + flatObjectMetadatas.map((flatObjectMetadata) => [ + flatObjectMetadata.universalIdentifier, + flatObjectMetadata, + ]), + ), + universalIdentifierById: Object.fromEntries( + flatObjectMetadatas.map((flatObjectMetadata) => [ + flatObjectMetadata.id, + flatObjectMetadata.universalIdentifier, + ]), + ), + universalIdentifiersByApplicationId: {}, +}); + +const buildFlatFieldMetadataMaps = ( + flatFieldMetadatas: FlatFieldMetadata[], +): FlatEntityMaps => ({ + byUniversalIdentifier: Object.fromEntries( + flatFieldMetadatas.map((flatFieldMetadata) => [ + flatFieldMetadata.universalIdentifier, + flatFieldMetadata, + ]), + ), + universalIdentifierById: Object.fromEntries( + flatFieldMetadatas.map((flatFieldMetadata) => [ + flatFieldMetadata.id, + flatFieldMetadata.universalIdentifier, + ]), + ), + universalIdentifiersByApplicationId: {}, +}); + +const buildFlatSearchFieldMetadataMaps = ( + flatSearchFieldMetadatas: FlatSearchFieldMetadata[], +): FlatEntityMaps => ({ + byUniversalIdentifier: Object.fromEntries( + flatSearchFieldMetadatas.map((flatSearchFieldMetadata) => [ + flatSearchFieldMetadata.universalIdentifier, + flatSearchFieldMetadata, + ]), + ), + universalIdentifierById: Object.fromEntries( + flatSearchFieldMetadatas.map((flatSearchFieldMetadata) => [ + flatSearchFieldMetadata.id, + flatSearchFieldMetadata.universalIdentifier, + ]), + ), + universalIdentifiersByApplicationId: {}, +}); + +const buildFlatSearchFieldMetadata = ({ + id, + universalIdentifier, + objectMetadataId, + fieldMetadataId, + applicationId, + applicationUniversalIdentifier, +}: { + id: string; + universalIdentifier: string; + objectMetadataId: string; + fieldMetadataId: string; + applicationId: string; + applicationUniversalIdentifier: string; +}): FlatSearchFieldMetadata => { + const createdAt = '2024-01-01T00:00:00.000Z'; + + return { + id, + universalIdentifier, + objectMetadataId, + fieldMetadataId, + objectMetadataUniversalIdentifier: `${objectMetadataId}-uid`, + fieldMetadataUniversalIdentifier: `${fieldMetadataId}-uid`, + tsVectorFieldMetadataId: `${objectMetadataId}-search-vector-field-id`, + tsVectorFieldMetadataUniversalIdentifier: `${objectMetadataId}-search-vector-field-uid`, + applicationId, + applicationUniversalIdentifier, + position: 0, + isSystemSideEffect: true, + workspaceId: 'workspace-id', + createdAt, + updatedAt: createdAt, + }; +}; + +const buildSearchableObjectFixture = ({ + objectId, + objectUniversalIdentifier, + applicationId, + applicationUniversalIdentifier, + labelIdentifierFieldType = FieldMetadataType.TEXT, +}: { + objectId: string; + objectUniversalIdentifier: string; + applicationId: string; + applicationUniversalIdentifier: string; + labelIdentifierFieldType?: FieldMetadataType; +}) => { + const nameFlatFieldMetadata = getFlatFieldMetadataMock({ + id: `${objectId}-name-field-id`, + universalIdentifier: `${objectUniversalIdentifier}-name-field-uid`, + objectMetadataId: objectId, + objectMetadataUniversalIdentifier: objectUniversalIdentifier, + type: labelIdentifierFieldType, + name: 'name', + applicationId, + applicationUniversalIdentifier, + }); + + const searchVectorFlatFieldMetadata = getFlatFieldMetadataMock({ + id: `${objectId}-search-vector-field-id`, + universalIdentifier: `${objectUniversalIdentifier}-search-vector-field-uid`, + objectMetadataId: objectId, + objectMetadataUniversalIdentifier: objectUniversalIdentifier, + type: FieldMetadataType.TS_VECTOR, + name: SEARCH_VECTOR_FIELD.name, + applicationId, + applicationUniversalIdentifier, + }); + + const flatObjectMetadata = getFlatObjectMetadataMock({ + id: objectId, + universalIdentifier: objectUniversalIdentifier, + isSearchable: true, + applicationId, + applicationUniversalIdentifier, + labelIdentifierFieldMetadataId: nameFlatFieldMetadata.id, + labelIdentifierFieldMetadataUniversalIdentifier: + nameFlatFieldMetadata.universalIdentifier, + fieldUniversalIdentifiers: [ + nameFlatFieldMetadata.universalIdentifier, + searchVectorFlatFieldMetadata.universalIdentifier, + ], + }); + + return { + flatObjectMetadata, + nameFlatFieldMetadata, + searchVectorFlatFieldMetadata, + }; +}; + +describe('buildSearchFieldMetadataBackfillOperations', () => { + it('creates the searchFieldMetadata row for an installed-app searchable object that has none, grouped under its application', () => { + const { + flatObjectMetadata, + nameFlatFieldMetadata, + searchVectorFlatFieldMetadata, + } = buildSearchableObjectFixture({ + objectId: 'installed-object-id', + objectUniversalIdentifier: 'installed-object-uid', + applicationId: INSTALLED_APPLICATION_ID, + applicationUniversalIdentifier: INSTALLED_APPLICATION_UID, + }); + + const flatSearchFieldMetadatasToCreateByApplicationUniversalIdentifier = + buildSearchFieldMetadataBackfillOperations({ + flatObjectMetadataMaps: buildFlatObjectMetadataMaps([ + flatObjectMetadata, + ]), + flatFieldMetadataMaps: buildFlatFieldMetadataMaps([ + nameFlatFieldMetadata, + searchVectorFlatFieldMetadata, + ]), + flatSearchFieldMetadataMaps: buildFlatSearchFieldMetadataMaps([]), + applicationUniversalIdentifierById, + twentyStandardApplicationId: STANDARD_APPLICATION_ID, + workspaceCustomApplicationId: CUSTOM_APPLICATION_ID, + }); + + const createdRows = + flatSearchFieldMetadatasToCreateByApplicationUniversalIdentifier[ + INSTALLED_APPLICATION_UID + ]; + + expect(createdRows).toHaveLength(1); + expect(createdRows?.[0].fieldMetadataUniversalIdentifier).toBe( + nameFlatFieldMetadata.universalIdentifier, + ); + expect(createdRows?.[0].objectMetadataUniversalIdentifier).toBe( + flatObjectMetadata.universalIdentifier, + ); + expect(createdRows?.[0].tsVectorFieldMetadataUniversalIdentifier).toBe( + searchVectorFlatFieldMetadata.universalIdentifier, + ); + expect(createdRows?.[0].universalIdentifier).toBe( + getSearchFieldUniversalIdentifier({ + applicationUniversalIdentifier: INSTALLED_APPLICATION_UID, + fieldMetadataUniversalIdentifier: + nameFlatFieldMetadata.universalIdentifier, + }), + ); + }); + + it('does not create a row for an installed-app junction object whose label identifier is the UUID id field', () => { + const { + flatObjectMetadata, + nameFlatFieldMetadata, + searchVectorFlatFieldMetadata, + } = buildSearchableObjectFixture({ + objectId: 'installed-junction-object-id', + objectUniversalIdentifier: 'installed-junction-object-uid', + applicationId: INSTALLED_APPLICATION_ID, + applicationUniversalIdentifier: INSTALLED_APPLICATION_UID, + }); + + // The label identifier resolves to the derived `id` field: no search surface. + const junctionObject: FlatObjectMetadata = { + ...flatObjectMetadata, + labelIdentifierFieldMetadataUniversalIdentifier: + getFieldUniversalIdentifier({ + applicationUniversalIdentifier: INSTALLED_APPLICATION_UID, + objectUniversalIdentifier: flatObjectMetadata.universalIdentifier, + name: 'id', + }), + }; + + const flatSearchFieldMetadatasToCreateByApplicationUniversalIdentifier = + buildSearchFieldMetadataBackfillOperations({ + flatObjectMetadataMaps: buildFlatObjectMetadataMaps([junctionObject]), + flatFieldMetadataMaps: buildFlatFieldMetadataMaps([ + nameFlatFieldMetadata, + searchVectorFlatFieldMetadata, + ]), + flatSearchFieldMetadataMaps: buildFlatSearchFieldMetadataMaps([]), + applicationUniversalIdentifierById, + twentyStandardApplicationId: STANDARD_APPLICATION_ID, + workspaceCustomApplicationId: CUSTOM_APPLICATION_ID, + }); + + expect( + Object.keys( + flatSearchFieldMetadatasToCreateByApplicationUniversalIdentifier, + ), + ).toHaveLength(0); + }); + + it('does not create a row for an installed-app object whose label identifier is a non-searchable type', () => { + const { + flatObjectMetadata, + nameFlatFieldMetadata, + searchVectorFlatFieldMetadata, + } = buildSearchableObjectFixture({ + objectId: 'installed-relation-object-id', + objectUniversalIdentifier: 'installed-relation-object-uid', + applicationId: INSTALLED_APPLICATION_ID, + applicationUniversalIdentifier: INSTALLED_APPLICATION_UID, + labelIdentifierFieldType: FieldMetadataType.RELATION, + }); + + const flatSearchFieldMetadatasToCreateByApplicationUniversalIdentifier = + buildSearchFieldMetadataBackfillOperations({ + flatObjectMetadataMaps: buildFlatObjectMetadataMaps([ + flatObjectMetadata, + ]), + flatFieldMetadataMaps: buildFlatFieldMetadataMaps([ + nameFlatFieldMetadata, + searchVectorFlatFieldMetadata, + ]), + flatSearchFieldMetadataMaps: buildFlatSearchFieldMetadataMaps([]), + applicationUniversalIdentifierById, + twentyStandardApplicationId: STANDARD_APPLICATION_ID, + workspaceCustomApplicationId: CUSTOM_APPLICATION_ID, + }); + + expect( + Object.keys( + flatSearchFieldMetadatasToCreateByApplicationUniversalIdentifier, + ), + ).toHaveLength(0); + }); + + it('does not create a row for a twenty-standard searchable object that has none', () => { + const { + flatObjectMetadata, + nameFlatFieldMetadata, + searchVectorFlatFieldMetadata, + } = buildSearchableObjectFixture({ + objectId: 'standard-object-id', + objectUniversalIdentifier: 'standard-object-uid', + applicationId: STANDARD_APPLICATION_ID, + applicationUniversalIdentifier: STANDARD_APPLICATION_UID, + }); + + const flatSearchFieldMetadatasToCreateByApplicationUniversalIdentifier = + buildSearchFieldMetadataBackfillOperations({ + flatObjectMetadataMaps: buildFlatObjectMetadataMaps([ + flatObjectMetadata, + ]), + flatFieldMetadataMaps: buildFlatFieldMetadataMaps([ + nameFlatFieldMetadata, + searchVectorFlatFieldMetadata, + ]), + flatSearchFieldMetadataMaps: buildFlatSearchFieldMetadataMaps([]), + applicationUniversalIdentifierById, + twentyStandardApplicationId: STANDARD_APPLICATION_ID, + workspaceCustomApplicationId: CUSTOM_APPLICATION_ID, + }); + + expect( + Object.keys( + flatSearchFieldMetadatasToCreateByApplicationUniversalIdentifier, + ), + ).toHaveLength(0); + }); + + it('does not create a row for a workspace-custom searchable object that has none', () => { + const { + flatObjectMetadata, + nameFlatFieldMetadata, + searchVectorFlatFieldMetadata, + } = buildSearchableObjectFixture({ + objectId: 'custom-object-id', + objectUniversalIdentifier: 'custom-object-uid', + applicationId: CUSTOM_APPLICATION_ID, + applicationUniversalIdentifier: CUSTOM_APPLICATION_UID, + }); + + const flatSearchFieldMetadatasToCreateByApplicationUniversalIdentifier = + buildSearchFieldMetadataBackfillOperations({ + flatObjectMetadataMaps: buildFlatObjectMetadataMaps([ + flatObjectMetadata, + ]), + flatFieldMetadataMaps: buildFlatFieldMetadataMaps([ + nameFlatFieldMetadata, + searchVectorFlatFieldMetadata, + ]), + flatSearchFieldMetadataMaps: buildFlatSearchFieldMetadataMaps([]), + applicationUniversalIdentifierById, + twentyStandardApplicationId: STANDARD_APPLICATION_ID, + workspaceCustomApplicationId: CUSTOM_APPLICATION_ID, + }); + + expect( + Object.keys( + flatSearchFieldMetadatasToCreateByApplicationUniversalIdentifier, + ), + ).toHaveLength(0); + }); + + it('is idempotent: no create when the installed-app searchFieldMetadata row already exists', () => { + const { + flatObjectMetadata, + nameFlatFieldMetadata, + searchVectorFlatFieldMetadata, + } = buildSearchableObjectFixture({ + objectId: 'installed-object-id', + objectUniversalIdentifier: 'installed-object-uid', + applicationId: INSTALLED_APPLICATION_ID, + applicationUniversalIdentifier: INSTALLED_APPLICATION_UID, + }); + + const existingSearchFieldMetadata = buildFlatSearchFieldMetadata({ + id: 'existing-search-field-metadata-id', + universalIdentifier: getSearchFieldUniversalIdentifier({ + applicationUniversalIdentifier: INSTALLED_APPLICATION_UID, + fieldMetadataUniversalIdentifier: + nameFlatFieldMetadata.universalIdentifier, + }), + objectMetadataId: flatObjectMetadata.id, + fieldMetadataId: nameFlatFieldMetadata.id, + applicationId: INSTALLED_APPLICATION_ID, + applicationUniversalIdentifier: INSTALLED_APPLICATION_UID, + }); + + const flatSearchFieldMetadatasToCreateByApplicationUniversalIdentifier = + buildSearchFieldMetadataBackfillOperations({ + flatObjectMetadataMaps: buildFlatObjectMetadataMaps([ + flatObjectMetadata, + ]), + flatFieldMetadataMaps: buildFlatFieldMetadataMaps([ + nameFlatFieldMetadata, + searchVectorFlatFieldMetadata, + ]), + flatSearchFieldMetadataMaps: buildFlatSearchFieldMetadataMaps([ + existingSearchFieldMetadata, + ]), + applicationUniversalIdentifierById, + twentyStandardApplicationId: STANDARD_APPLICATION_ID, + workspaceCustomApplicationId: CUSTOM_APPLICATION_ID, + }); + + expect( + Object.keys( + flatSearchFieldMetadatasToCreateByApplicationUniversalIdentifier, + ), + ).toHaveLength(0); + }); +}); diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/2-20/utils/__tests__/build-search-field-metadata-re-own-operations.util.spec.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/2-20/utils/__tests__/build-search-field-metadata-re-own-operations.util.spec.ts new file mode 100644 index 0000000000..72758f4972 --- /dev/null +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/2-20/utils/__tests__/build-search-field-metadata-re-own-operations.util.spec.ts @@ -0,0 +1,155 @@ +import { getSearchFieldUniversalIdentifier } from 'twenty-shared/application'; +import { FieldMetadataType } from 'twenty-shared/types'; + +import { buildSearchFieldMetadataReOwnOperations } from 'src/database/commands/upgrade-version-command/2-20/utils/build-search-field-metadata-re-own-operations.util'; +import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type'; +import { getFlatFieldMetadataMock } from 'src/engine/metadata-modules/flat-field-metadata/__mocks__/get-flat-field-metadata.mock'; +import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type'; +import { type FlatSearchFieldMetadata } from 'src/engine/metadata-modules/flat-search-field-metadata/types/flat-search-field-metadata.type'; + +const STANDARD_APPLICATION_ID = 'standard-application-id'; +// Application universal identifiers must be valid UUIDs: they are used as the v5 +// namespace by the deterministic identifier helpers. +const STANDARD_APPLICATION_UID = '11111111-1111-4111-8111-111111111111'; + +const applicationUniversalIdentifierById = new Map([ + [STANDARD_APPLICATION_ID, STANDARD_APPLICATION_UID], +]); + +const buildFlatFieldMetadataMaps = ( + flatFieldMetadatas: FlatFieldMetadata[], +): FlatEntityMaps => ({ + byUniversalIdentifier: Object.fromEntries( + flatFieldMetadatas.map((flatFieldMetadata) => [ + flatFieldMetadata.universalIdentifier, + flatFieldMetadata, + ]), + ), + universalIdentifierById: Object.fromEntries( + flatFieldMetadatas.map((flatFieldMetadata) => [ + flatFieldMetadata.id, + flatFieldMetadata.universalIdentifier, + ]), + ), + universalIdentifiersByApplicationId: {}, +}); + +const buildFlatSearchFieldMetadataMaps = ( + flatSearchFieldMetadatas: FlatSearchFieldMetadata[], +): FlatEntityMaps => ({ + byUniversalIdentifier: Object.fromEntries( + flatSearchFieldMetadatas.map((flatSearchFieldMetadata) => [ + flatSearchFieldMetadata.universalIdentifier, + flatSearchFieldMetadata, + ]), + ), + universalIdentifierById: Object.fromEntries( + flatSearchFieldMetadatas.map((flatSearchFieldMetadata) => [ + flatSearchFieldMetadata.id, + flatSearchFieldMetadata.universalIdentifier, + ]), + ), + universalIdentifiersByApplicationId: {}, +}); + +const buildFlatSearchFieldMetadata = ({ + id, + universalIdentifier, + objectMetadataId, + fieldMetadataId, +}: { + id: string; + universalIdentifier: string; + objectMetadataId: string; + fieldMetadataId: string; +}): FlatSearchFieldMetadata => { + const createdAt = '2024-01-01T00:00:00.000Z'; + + return { + id, + universalIdentifier, + objectMetadataId, + fieldMetadataId, + objectMetadataUniversalIdentifier: `${objectMetadataId}-uid`, + fieldMetadataUniversalIdentifier: `${fieldMetadataId}-uid`, + tsVectorFieldMetadataId: `${objectMetadataId}-search-vector-field-id`, + tsVectorFieldMetadataUniversalIdentifier: `${objectMetadataId}-search-vector-field-uid`, + applicationId: STANDARD_APPLICATION_ID, + applicationUniversalIdentifier: STANDARD_APPLICATION_UID, + position: 0, + isSystemSideEffect: true, + workspaceId: 'workspace-id', + createdAt, + updatedAt: createdAt, + }; +}; + +const nameFlatFieldMetadata = getFlatFieldMetadataMock({ + id: 'standard-object-name-field-id', + universalIdentifier: 'standard-object-name-field-uid', + objectMetadataId: 'standard-object-id', + objectMetadataUniversalIdentifier: 'standard-object-uid', + type: FieldMetadataType.TEXT, + name: 'name', + applicationId: STANDARD_APPLICATION_ID, + applicationUniversalIdentifier: STANDARD_APPLICATION_UID, +}); + +describe('buildSearchFieldMetadataReOwnOperations', () => { + it('re-owns an existing searchFieldMetadata carrying a non-deterministic universal identifier (all applications)', () => { + const existingSearchFieldMetadata = buildFlatSearchFieldMetadata({ + id: 'existing-search-field-metadata-id', + universalIdentifier: 'legacy-v4-search-field-metadata-uid', + objectMetadataId: 'standard-object-id', + fieldMetadataId: nameFlatFieldMetadata.id, + }); + + const searchFieldMetadataUniversalIdentifierUpdates = + buildSearchFieldMetadataReOwnOperations({ + flatFieldMetadataMaps: buildFlatFieldMetadataMaps([ + nameFlatFieldMetadata, + ]), + flatSearchFieldMetadataMaps: buildFlatSearchFieldMetadataMaps([ + existingSearchFieldMetadata, + ]), + applicationUniversalIdentifierById, + }); + + expect(searchFieldMetadataUniversalIdentifierUpdates).toEqual([ + { + id: existingSearchFieldMetadata.id, + deterministicUniversalIdentifier: getSearchFieldUniversalIdentifier({ + applicationUniversalIdentifier: STANDARD_APPLICATION_UID, + fieldMetadataUniversalIdentifier: + nameFlatFieldMetadata.universalIdentifier, + }), + }, + ]); + }); + + it('is a no-op for a searchFieldMetadata already carrying its deterministic universal identifier', () => { + const existingSearchFieldMetadata = buildFlatSearchFieldMetadata({ + id: 'existing-search-field-metadata-id', + universalIdentifier: getSearchFieldUniversalIdentifier({ + applicationUniversalIdentifier: STANDARD_APPLICATION_UID, + fieldMetadataUniversalIdentifier: + nameFlatFieldMetadata.universalIdentifier, + }), + objectMetadataId: 'standard-object-id', + fieldMetadataId: nameFlatFieldMetadata.id, + }); + + const searchFieldMetadataUniversalIdentifierUpdates = + buildSearchFieldMetadataReOwnOperations({ + flatFieldMetadataMaps: buildFlatFieldMetadataMaps([ + nameFlatFieldMetadata, + ]), + flatSearchFieldMetadataMaps: buildFlatSearchFieldMetadataMaps([ + existingSearchFieldMetadata, + ]), + applicationUniversalIdentifierById, + }); + + expect(searchFieldMetadataUniversalIdentifierUpdates).toHaveLength(0); + }); +}); diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/2-20/utils/__tests__/build-search-vector-gin-index-backfill-operations.util.spec.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/2-20/utils/__tests__/build-search-vector-gin-index-backfill-operations.util.spec.ts new file mode 100644 index 0000000000..bb9f0a0432 --- /dev/null +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/2-20/utils/__tests__/build-search-vector-gin-index-backfill-operations.util.spec.ts @@ -0,0 +1,267 @@ +import { FieldMetadataType } from 'twenty-shared/types'; + +import { buildSearchVectorGinIndexBackfillOperations } from 'src/database/commands/upgrade-version-command/2-20/utils/build-search-vector-gin-index-backfill-operations.util'; +import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type'; +import { getFlatFieldMetadataMock } from 'src/engine/metadata-modules/flat-field-metadata/__mocks__/get-flat-field-metadata.mock'; +import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type'; +import { getFlatIndexMetadataMock } from 'src/engine/metadata-modules/flat-index-metadata/__mocks__/get-flat-index-metadata.mock'; +import { + type FlatIndexFieldMetadata, + type FlatIndexMetadata, +} from 'src/engine/metadata-modules/flat-index-metadata/types/flat-index-metadata.type'; +import { getFlatObjectMetadataMock } from 'src/engine/metadata-modules/flat-object-metadata/__mocks__/get-flat-object-metadata.mock'; +import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type'; +import { IndexType } from 'src/engine/metadata-modules/index-metadata/types/indexType.types'; +import { SEARCH_VECTOR_FIELD } from 'src/engine/metadata-modules/search-field-metadata/constants/search-vector-field.constants'; + +// Application universal identifiers must be valid UUIDs: they are used as the v5 +// namespace by the deterministic identifier helpers. +const STANDARD_APPLICATION_ID = 'standard-application-id'; +const CUSTOM_APPLICATION_ID = 'custom-application-id'; +const INSTALLED_APPLICATION_ID = 'installed-application-id'; +const INSTALLED_APPLICATION_UID = '33333333-3333-4333-8333-333333333333'; + +const buildFlatObjectMetadataMaps = ( + flatObjectMetadatas: FlatObjectMetadata[], +): FlatEntityMaps => ({ + byUniversalIdentifier: Object.fromEntries( + flatObjectMetadatas.map((flatObjectMetadata) => [ + flatObjectMetadata.universalIdentifier, + flatObjectMetadata, + ]), + ), + universalIdentifierById: Object.fromEntries( + flatObjectMetadatas.map((flatObjectMetadata) => [ + flatObjectMetadata.id, + flatObjectMetadata.universalIdentifier, + ]), + ), + universalIdentifiersByApplicationId: {}, +}); + +const buildFlatFieldMetadataMaps = ( + flatFieldMetadatas: FlatFieldMetadata[], +): FlatEntityMaps => ({ + byUniversalIdentifier: Object.fromEntries( + flatFieldMetadatas.map((flatFieldMetadata) => [ + flatFieldMetadata.universalIdentifier, + flatFieldMetadata, + ]), + ), + universalIdentifierById: Object.fromEntries( + flatFieldMetadatas.map((flatFieldMetadata) => [ + flatFieldMetadata.id, + flatFieldMetadata.universalIdentifier, + ]), + ), + universalIdentifiersByApplicationId: {}, +}); + +const buildFlatIndexMaps = ( + flatIndexMetadatas: FlatIndexMetadata[], +): FlatEntityMaps => ({ + byUniversalIdentifier: Object.fromEntries( + flatIndexMetadatas.map((flatIndexMetadata) => [ + flatIndexMetadata.universalIdentifier, + flatIndexMetadata, + ]), + ), + universalIdentifierById: Object.fromEntries( + flatIndexMetadatas.map((flatIndexMetadata) => [ + flatIndexMetadata.id, + flatIndexMetadata.universalIdentifier, + ]), + ), + universalIdentifiersByApplicationId: {}, +}); + +const buildFlatIndexFieldMetadata = ({ + fieldMetadataId, +}: { + fieldMetadataId: string; +}): FlatIndexFieldMetadata => { + const createdAt = '2024-01-01T00:00:00.000Z'; + + return { + id: `${fieldMetadataId}-index-field-id`, + workspaceId: 'workspace-id', + indexMetadataId: 'index-metadata-id', + fieldMetadataId, + order: 0, + subFieldName: null, + createdAt, + updatedAt: createdAt, + }; +}; + +const buildSearchableObjectFixture = ({ + objectId, + objectUniversalIdentifier, + applicationId, + applicationUniversalIdentifier, +}: { + objectId: string; + objectUniversalIdentifier: string; + applicationId: string; + applicationUniversalIdentifier: string; +}) => { + const searchVectorFlatFieldMetadata = getFlatFieldMetadataMock({ + id: `${objectId}-search-vector-field-id`, + universalIdentifier: `${objectUniversalIdentifier}-search-vector-field-uid`, + objectMetadataId: objectId, + objectMetadataUniversalIdentifier: objectUniversalIdentifier, + type: FieldMetadataType.TS_VECTOR, + name: SEARCH_VECTOR_FIELD.name, + applicationId, + applicationUniversalIdentifier, + }); + + const flatObjectMetadata = getFlatObjectMetadataMock({ + id: objectId, + universalIdentifier: objectUniversalIdentifier, + isSearchable: true, + applicationId, + applicationUniversalIdentifier, + fieldUniversalIdentifiers: [ + searchVectorFlatFieldMetadata.universalIdentifier, + ], + }); + + return { flatObjectMetadata, searchVectorFlatFieldMetadata }; +}; + +describe('buildSearchVectorGinIndexBackfillOperations', () => { + it('creates a GIN searchVector index for an installed-app object that has none, grouped under its application', () => { + const { flatObjectMetadata, searchVectorFlatFieldMetadata } = + buildSearchableObjectFixture({ + objectId: 'installed-object-id', + objectUniversalIdentifier: 'installed-object-uid', + applicationId: INSTALLED_APPLICATION_ID, + applicationUniversalIdentifier: INSTALLED_APPLICATION_UID, + }); + + const flatIndexesToCreateByApplicationUniversalIdentifier = + buildSearchVectorGinIndexBackfillOperations({ + flatObjectMetadataMaps: buildFlatObjectMetadataMaps([ + flatObjectMetadata, + ]), + flatFieldMetadataMaps: buildFlatFieldMetadataMaps([ + searchVectorFlatFieldMetadata, + ]), + flatIndexMaps: buildFlatIndexMaps([]), + twentyStandardApplicationId: STANDARD_APPLICATION_ID, + workspaceCustomApplicationId: CUSTOM_APPLICATION_ID, + }); + + const createdIndexes = + flatIndexesToCreateByApplicationUniversalIdentifier[ + INSTALLED_APPLICATION_UID + ]; + + expect(createdIndexes).toHaveLength(1); + expect(createdIndexes?.[0].indexType).toBe(IndexType.GIN); + expect(createdIndexes?.[0].objectMetadataUniversalIdentifier).toBe( + flatObjectMetadata.universalIdentifier, + ); + expect(createdIndexes?.[0].applicationUniversalIdentifier).toBe( + INSTALLED_APPLICATION_UID, + ); + }); + + it('does not create an index for a twenty-standard object that has none', () => { + const { flatObjectMetadata, searchVectorFlatFieldMetadata } = + buildSearchableObjectFixture({ + objectId: 'standard-object-id', + objectUniversalIdentifier: 'standard-object-uid', + applicationId: STANDARD_APPLICATION_ID, + applicationUniversalIdentifier: '11111111-1111-4111-8111-111111111111', + }); + + const flatIndexesToCreateByApplicationUniversalIdentifier = + buildSearchVectorGinIndexBackfillOperations({ + flatObjectMetadataMaps: buildFlatObjectMetadataMaps([ + flatObjectMetadata, + ]), + flatFieldMetadataMaps: buildFlatFieldMetadataMaps([ + searchVectorFlatFieldMetadata, + ]), + flatIndexMaps: buildFlatIndexMaps([]), + twentyStandardApplicationId: STANDARD_APPLICATION_ID, + workspaceCustomApplicationId: CUSTOM_APPLICATION_ID, + }); + + expect( + Object.keys(flatIndexesToCreateByApplicationUniversalIdentifier), + ).toHaveLength(0); + }); + + it('does not create an index for a workspace-custom object that has none', () => { + const { flatObjectMetadata, searchVectorFlatFieldMetadata } = + buildSearchableObjectFixture({ + objectId: 'custom-object-id', + objectUniversalIdentifier: 'custom-object-uid', + applicationId: CUSTOM_APPLICATION_ID, + applicationUniversalIdentifier: '22222222-2222-4222-8222-222222222222', + }); + + const flatIndexesToCreateByApplicationUniversalIdentifier = + buildSearchVectorGinIndexBackfillOperations({ + flatObjectMetadataMaps: buildFlatObjectMetadataMaps([ + flatObjectMetadata, + ]), + flatFieldMetadataMaps: buildFlatFieldMetadataMaps([ + searchVectorFlatFieldMetadata, + ]), + flatIndexMaps: buildFlatIndexMaps([]), + twentyStandardApplicationId: STANDARD_APPLICATION_ID, + workspaceCustomApplicationId: CUSTOM_APPLICATION_ID, + }); + + expect( + Object.keys(flatIndexesToCreateByApplicationUniversalIdentifier), + ).toHaveLength(0); + }); + + it('is idempotent: no create when the installed-app object already has a GIN searchVector index', () => { + const { flatObjectMetadata, searchVectorFlatFieldMetadata } = + buildSearchableObjectFixture({ + objectId: 'installed-object-id', + objectUniversalIdentifier: 'installed-object-uid', + applicationId: INSTALLED_APPLICATION_ID, + applicationUniversalIdentifier: INSTALLED_APPLICATION_UID, + }); + + const ginIndex = getFlatIndexMetadataMock({ + id: 'installed-gin-index-id', + universalIdentifier: 'installed-gin-index-uid', + objectMetadataId: flatObjectMetadata.id, + objectMetadataUniversalIdentifier: flatObjectMetadata.universalIdentifier, + applicationId: INSTALLED_APPLICATION_ID, + applicationUniversalIdentifier: INSTALLED_APPLICATION_UID, + name: 'IDX_SEARCH_VECTOR_INSTALLED', + indexType: IndexType.GIN, + flatIndexFieldMetadatas: [ + buildFlatIndexFieldMetadata({ + fieldMetadataId: searchVectorFlatFieldMetadata.id, + }), + ], + }); + + const flatIndexesToCreateByApplicationUniversalIdentifier = + buildSearchVectorGinIndexBackfillOperations({ + flatObjectMetadataMaps: buildFlatObjectMetadataMaps([ + flatObjectMetadata, + ]), + flatFieldMetadataMaps: buildFlatFieldMetadataMaps([ + searchVectorFlatFieldMetadata, + ]), + flatIndexMaps: buildFlatIndexMaps([ginIndex]), + twentyStandardApplicationId: STANDARD_APPLICATION_ID, + workspaceCustomApplicationId: CUSTOM_APPLICATION_ID, + }); + + expect( + Object.keys(flatIndexesToCreateByApplicationUniversalIdentifier), + ).toHaveLength(0); + }); +}); diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/2-20/utils/__tests__/build-search-vector-gin-index-re-own-operations.util.spec.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/2-20/utils/__tests__/build-search-vector-gin-index-re-own-operations.util.spec.ts new file mode 100644 index 0000000000..ff8285ccba --- /dev/null +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/2-20/utils/__tests__/build-search-vector-gin-index-re-own-operations.util.spec.ts @@ -0,0 +1,224 @@ +import { getIndexUniversalIdentifier } from 'twenty-shared/application'; +import { FieldMetadataType } from 'twenty-shared/types'; + +import { buildSearchVectorGinIndexReOwnOperations } from 'src/database/commands/upgrade-version-command/2-20/utils/build-search-vector-gin-index-re-own-operations.util'; +import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type'; +import { getFlatFieldMetadataMock } from 'src/engine/metadata-modules/flat-field-metadata/__mocks__/get-flat-field-metadata.mock'; +import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type'; +import { getFlatIndexMetadataMock } from 'src/engine/metadata-modules/flat-index-metadata/__mocks__/get-flat-index-metadata.mock'; +import { + type FlatIndexFieldMetadata, + type FlatIndexMetadata, +} from 'src/engine/metadata-modules/flat-index-metadata/types/flat-index-metadata.type'; +import { getFlatObjectMetadataMock } from 'src/engine/metadata-modules/flat-object-metadata/__mocks__/get-flat-object-metadata.mock'; +import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type'; +import { IndexType } from 'src/engine/metadata-modules/index-metadata/types/indexType.types'; +import { SEARCH_VECTOR_FIELD } from 'src/engine/metadata-modules/search-field-metadata/constants/search-vector-field.constants'; + +const STANDARD_APPLICATION_ID = 'standard-application-id'; +// Application universal identifiers must be valid UUIDs: they are used as the v5 +// namespace by the deterministic identifier helpers. +const STANDARD_APPLICATION_UID = '11111111-1111-4111-8111-111111111111'; + +const applicationUniversalIdentifierById = new Map([ + [STANDARD_APPLICATION_ID, STANDARD_APPLICATION_UID], +]); + +const buildFlatObjectMetadataMaps = ( + flatObjectMetadatas: FlatObjectMetadata[], +): FlatEntityMaps => ({ + byUniversalIdentifier: Object.fromEntries( + flatObjectMetadatas.map((flatObjectMetadata) => [ + flatObjectMetadata.universalIdentifier, + flatObjectMetadata, + ]), + ), + universalIdentifierById: Object.fromEntries( + flatObjectMetadatas.map((flatObjectMetadata) => [ + flatObjectMetadata.id, + flatObjectMetadata.universalIdentifier, + ]), + ), + universalIdentifiersByApplicationId: {}, +}); + +const buildFlatFieldMetadataMaps = ( + flatFieldMetadatas: FlatFieldMetadata[], +): FlatEntityMaps => ({ + byUniversalIdentifier: Object.fromEntries( + flatFieldMetadatas.map((flatFieldMetadata) => [ + flatFieldMetadata.universalIdentifier, + flatFieldMetadata, + ]), + ), + universalIdentifierById: Object.fromEntries( + flatFieldMetadatas.map((flatFieldMetadata) => [ + flatFieldMetadata.id, + flatFieldMetadata.universalIdentifier, + ]), + ), + universalIdentifiersByApplicationId: {}, +}); + +const buildFlatIndexMaps = ( + flatIndexMetadatas: FlatIndexMetadata[], +): FlatEntityMaps => ({ + byUniversalIdentifier: Object.fromEntries( + flatIndexMetadatas.map((flatIndexMetadata) => [ + flatIndexMetadata.universalIdentifier, + flatIndexMetadata, + ]), + ), + universalIdentifierById: Object.fromEntries( + flatIndexMetadatas.map((flatIndexMetadata) => [ + flatIndexMetadata.id, + flatIndexMetadata.universalIdentifier, + ]), + ), + universalIdentifiersByApplicationId: {}, +}); + +const buildFlatIndexFieldMetadata = ({ + fieldMetadataId, +}: { + fieldMetadataId: string; +}): FlatIndexFieldMetadata => { + const createdAt = '2024-01-01T00:00:00.000Z'; + + return { + id: `${fieldMetadataId}-index-field-id`, + workspaceId: 'workspace-id', + indexMetadataId: 'index-metadata-id', + fieldMetadataId, + order: 0, + subFieldName: null, + createdAt, + updatedAt: createdAt, + }; +}; + +const buildSearchableObjectFixture = ({ + objectId, + objectUniversalIdentifier, +}: { + objectId: string; + objectUniversalIdentifier: string; +}) => { + const searchVectorFlatFieldMetadata = getFlatFieldMetadataMock({ + id: `${objectId}-search-vector-field-id`, + universalIdentifier: `${objectUniversalIdentifier}-search-vector-field-uid`, + objectMetadataId: objectId, + objectMetadataUniversalIdentifier: objectUniversalIdentifier, + type: FieldMetadataType.TS_VECTOR, + name: SEARCH_VECTOR_FIELD.name, + applicationId: STANDARD_APPLICATION_ID, + applicationUniversalIdentifier: STANDARD_APPLICATION_UID, + }); + + const flatObjectMetadata = getFlatObjectMetadataMock({ + id: objectId, + universalIdentifier: objectUniversalIdentifier, + isSearchable: true, + applicationId: STANDARD_APPLICATION_ID, + applicationUniversalIdentifier: STANDARD_APPLICATION_UID, + fieldUniversalIdentifiers: [ + searchVectorFlatFieldMetadata.universalIdentifier, + ], + }); + + return { flatObjectMetadata, searchVectorFlatFieldMetadata }; +}; + +describe('buildSearchVectorGinIndexReOwnOperations', () => { + it('re-owns an existing GIN searchVector index carrying a non-deterministic universal identifier (all applications)', () => { + const { flatObjectMetadata, searchVectorFlatFieldMetadata } = + buildSearchableObjectFixture({ + objectId: 'standard-object-id', + objectUniversalIdentifier: 'standard-object-uid', + }); + + const indexName = 'IDX_SEARCH_VECTOR_STANDARD'; + const ginIndex = getFlatIndexMetadataMock({ + id: 'standard-gin-index-id', + universalIdentifier: 'legacy-v4-index-uid', + objectMetadataId: flatObjectMetadata.id, + objectMetadataUniversalIdentifier: flatObjectMetadata.universalIdentifier, + applicationId: STANDARD_APPLICATION_ID, + applicationUniversalIdentifier: STANDARD_APPLICATION_UID, + name: indexName, + indexType: IndexType.GIN, + flatIndexFieldMetadatas: [ + buildFlatIndexFieldMetadata({ + fieldMetadataId: searchVectorFlatFieldMetadata.id, + }), + ], + }); + + const indexUniversalIdentifierUpdates = + buildSearchVectorGinIndexReOwnOperations({ + flatObjectMetadataMaps: buildFlatObjectMetadataMaps([ + flatObjectMetadata, + ]), + flatFieldMetadataMaps: buildFlatFieldMetadataMaps([ + searchVectorFlatFieldMetadata, + ]), + flatIndexMaps: buildFlatIndexMaps([ginIndex]), + applicationUniversalIdentifierById, + }); + + expect(indexUniversalIdentifierUpdates).toEqual([ + { + id: ginIndex.id, + name: indexName, + deterministicUniversalIdentifier: getIndexUniversalIdentifier({ + applicationUniversalIdentifier: STANDARD_APPLICATION_UID, + objectUniversalIdentifier: flatObjectMetadata.universalIdentifier, + name: indexName, + }), + }, + ]); + }); + + it('is a no-op for a GIN searchVector index already carrying its deterministic universal identifier', () => { + const { flatObjectMetadata, searchVectorFlatFieldMetadata } = + buildSearchableObjectFixture({ + objectId: 'standard-object-id', + objectUniversalIdentifier: 'standard-object-uid', + }); + + const indexName = 'IDX_SEARCH_VECTOR_STANDARD'; + const ginIndex = getFlatIndexMetadataMock({ + id: 'standard-gin-index-id', + universalIdentifier: getIndexUniversalIdentifier({ + applicationUniversalIdentifier: STANDARD_APPLICATION_UID, + objectUniversalIdentifier: flatObjectMetadata.universalIdentifier, + name: indexName, + }), + objectMetadataId: flatObjectMetadata.id, + objectMetadataUniversalIdentifier: flatObjectMetadata.universalIdentifier, + applicationId: STANDARD_APPLICATION_ID, + applicationUniversalIdentifier: STANDARD_APPLICATION_UID, + name: indexName, + indexType: IndexType.GIN, + flatIndexFieldMetadatas: [ + buildFlatIndexFieldMetadata({ + fieldMetadataId: searchVectorFlatFieldMetadata.id, + }), + ], + }); + + const indexUniversalIdentifierUpdates = + buildSearchVectorGinIndexReOwnOperations({ + flatObjectMetadataMaps: buildFlatObjectMetadataMaps([ + flatObjectMetadata, + ]), + flatFieldMetadataMaps: buildFlatFieldMetadataMaps([ + searchVectorFlatFieldMetadata, + ]), + flatIndexMaps: buildFlatIndexMaps([ginIndex]), + applicationUniversalIdentifierById, + }); + + expect(indexUniversalIdentifierUpdates).toHaveLength(0); + }); +}); diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/2-20/utils/__tests__/is-search-vector-gin-flat-index-metadata.util.spec.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/2-20/utils/__tests__/is-search-vector-gin-flat-index-metadata.util.spec.ts new file mode 100644 index 0000000000..97c55cbfb3 --- /dev/null +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/2-20/utils/__tests__/is-search-vector-gin-flat-index-metadata.util.spec.ts @@ -0,0 +1,186 @@ +import { FieldMetadataType } from 'twenty-shared/types'; + +import { isSearchVectorGinFlatIndexMetadata } from 'src/database/commands/upgrade-version-command/2-20/utils/is-search-vector-gin-flat-index-metadata.util'; +import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type'; +import { getFlatFieldMetadataMock } from 'src/engine/metadata-modules/flat-field-metadata/__mocks__/get-flat-field-metadata.mock'; +import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type'; +import { getFlatIndexMetadataMock } from 'src/engine/metadata-modules/flat-index-metadata/__mocks__/get-flat-index-metadata.mock'; +import { type FlatIndexFieldMetadata } from 'src/engine/metadata-modules/flat-index-metadata/types/flat-index-metadata.type'; +import { IndexType } from 'src/engine/metadata-modules/index-metadata/types/indexType.types'; +import { SEARCH_VECTOR_FIELD } from 'src/engine/metadata-modules/search-field-metadata/constants/search-vector-field.constants'; + +const buildFlatFieldMetadataMaps = ( + flatFieldMetadatas: FlatFieldMetadata[], +): FlatEntityMaps => ({ + byUniversalIdentifier: Object.fromEntries( + flatFieldMetadatas.map((flatFieldMetadata) => [ + flatFieldMetadata.universalIdentifier, + flatFieldMetadata, + ]), + ), + universalIdentifierById: Object.fromEntries( + flatFieldMetadatas.map((flatFieldMetadata) => [ + flatFieldMetadata.id, + flatFieldMetadata.universalIdentifier, + ]), + ), + universalIdentifiersByApplicationId: {}, +}); + +const buildFlatIndexFieldMetadata = ({ + fieldMetadataId, +}: { + fieldMetadataId: string; +}): FlatIndexFieldMetadata => { + const createdAt = '2024-01-01T00:00:00.000Z'; + + return { + id: `${fieldMetadataId}-index-field-id`, + workspaceId: 'workspace-id', + indexMetadataId: 'index-metadata-id', + fieldMetadataId, + order: 0, + subFieldName: null, + createdAt, + updatedAt: createdAt, + }; +}; + +const SEARCH_VECTOR_FIELD_ID = 'search-vector-field-id'; +const TEXT_FIELD_ID = 'text-field-id'; +const OTHER_TS_VECTOR_FIELD_ID = 'other-ts-vector-field-id'; + +const searchVectorFlatFieldMetadata = getFlatFieldMetadataMock({ + id: SEARCH_VECTOR_FIELD_ID, + universalIdentifier: 'search-vector-field-uid', + objectMetadataId: 'object-id', + type: FieldMetadataType.TS_VECTOR, + name: SEARCH_VECTOR_FIELD.name, +}); + +const textFlatFieldMetadata = getFlatFieldMetadataMock({ + id: TEXT_FIELD_ID, + universalIdentifier: 'text-field-uid', + objectMetadataId: 'object-id', + type: FieldMetadataType.TEXT, + name: 'name', +}); + +const otherTsVectorFlatFieldMetadata = getFlatFieldMetadataMock({ + id: OTHER_TS_VECTOR_FIELD_ID, + universalIdentifier: 'other-ts-vector-field-uid', + objectMetadataId: 'object-id', + type: FieldMetadataType.TS_VECTOR, + name: 'customSearchVector', +}); + +const flatFieldMetadataMaps = buildFlatFieldMetadataMaps([ + searchVectorFlatFieldMetadata, + textFlatFieldMetadata, + otherTsVectorFlatFieldMetadata, +]); + +describe('isSearchVectorGinFlatIndexMetadata', () => { + it('returns true for a single-field GIN index on the TS_VECTOR field', () => { + const flatIndexMetadata = getFlatIndexMetadataMock({ + universalIdentifier: 'gin-index-uid', + objectMetadataId: 'object-id', + objectMetadataUniversalIdentifier: 'object-uid', + applicationUniversalIdentifier: 'application-uid', + indexType: IndexType.GIN, + flatIndexFieldMetadatas: [ + buildFlatIndexFieldMetadata({ fieldMetadataId: SEARCH_VECTOR_FIELD_ID }), + ], + }); + + expect( + isSearchVectorGinFlatIndexMetadata({ + flatIndexMetadata, + flatFieldMetadataMaps, + }), + ).toBe(true); + }); + + it('returns false for a GIN index on a non-TS_VECTOR field', () => { + const flatIndexMetadata = getFlatIndexMetadataMock({ + universalIdentifier: 'gin-index-uid', + objectMetadataId: 'object-id', + objectMetadataUniversalIdentifier: 'object-uid', + applicationUniversalIdentifier: 'application-uid', + indexType: IndexType.GIN, + flatIndexFieldMetadatas: [ + buildFlatIndexFieldMetadata({ fieldMetadataId: TEXT_FIELD_ID }), + ], + }); + + expect( + isSearchVectorGinFlatIndexMetadata({ + flatIndexMetadata, + flatFieldMetadataMaps, + }), + ).toBe(false); + }); + + it('returns false for a non-GIN (BTREE) index on the TS_VECTOR field', () => { + const flatIndexMetadata = getFlatIndexMetadataMock({ + universalIdentifier: 'btree-index-uid', + objectMetadataId: 'object-id', + objectMetadataUniversalIdentifier: 'object-uid', + applicationUniversalIdentifier: 'application-uid', + indexType: IndexType.BTREE, + flatIndexFieldMetadatas: [ + buildFlatIndexFieldMetadata({ fieldMetadataId: SEARCH_VECTOR_FIELD_ID }), + ], + }); + + expect( + isSearchVectorGinFlatIndexMetadata({ + flatIndexMetadata, + flatFieldMetadataMaps, + }), + ).toBe(false); + }); + + it('returns false for a single-field GIN index on a TS_VECTOR field that is not the searchVector field', () => { + const flatIndexMetadata = getFlatIndexMetadataMock({ + universalIdentifier: 'gin-index-uid', + objectMetadataId: 'object-id', + objectMetadataUniversalIdentifier: 'object-uid', + applicationUniversalIdentifier: 'application-uid', + indexType: IndexType.GIN, + flatIndexFieldMetadatas: [ + buildFlatIndexFieldMetadata({ + fieldMetadataId: OTHER_TS_VECTOR_FIELD_ID, + }), + ], + }); + + expect( + isSearchVectorGinFlatIndexMetadata({ + flatIndexMetadata, + flatFieldMetadataMaps, + }), + ).toBe(false); + }); + + it('returns false for a multi-column GIN index', () => { + const flatIndexMetadata = getFlatIndexMetadataMock({ + universalIdentifier: 'gin-index-uid', + objectMetadataId: 'object-id', + objectMetadataUniversalIdentifier: 'object-uid', + applicationUniversalIdentifier: 'application-uid', + indexType: IndexType.GIN, + flatIndexFieldMetadatas: [ + buildFlatIndexFieldMetadata({ fieldMetadataId: SEARCH_VECTOR_FIELD_ID }), + buildFlatIndexFieldMetadata({ fieldMetadataId: TEXT_FIELD_ID }), + ], + }); + + expect( + isSearchVectorGinFlatIndexMetadata({ + flatIndexMetadata, + flatFieldMetadataMaps, + }), + ).toBe(false); + }); +}); diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/2-20/utils/build-search-field-metadata-backfill-operations.util.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/2-20/utils/build-search-field-metadata-backfill-operations.util.ts new file mode 100644 index 0000000000..49198f367f --- /dev/null +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/2-20/utils/build-search-field-metadata-backfill-operations.util.ts @@ -0,0 +1,139 @@ +import { getFieldUniversalIdentifier } from 'twenty-shared/application'; +import { + fromArrayToValuesByKeyRecord, + isDefined, + isSearchableFieldType, +} from 'twenty-shared/utils'; + +import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type'; +import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type'; +import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type'; +import { type FlatSearchFieldMetadata } from 'src/engine/metadata-modules/flat-search-field-metadata/types/flat-search-field-metadata.type'; +import { buildFlatSearchFieldMetadataForField } from 'src/engine/metadata-modules/flat-search-field-metadata/utils/build-flat-search-field-metadata-for-field.util'; +import { findTsVectorFlatFieldMetadataForObject } from 'src/engine/metadata-modules/flat-search-field-metadata/utils/find-ts-vector-flat-field-metadata-for-object.util'; +import { type UniversalFlatSearchFieldMetadata } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-search-field-metadata.type'; + +type BuildSearchFieldMetadataBackfillOperationsArgs = { + flatObjectMetadataMaps: FlatEntityMaps; + flatFieldMetadataMaps: FlatEntityMaps; + flatSearchFieldMetadataMaps: FlatEntityMaps; + applicationUniversalIdentifierById: Map; + twentyStandardApplicationId: string; + workspaceCustomApplicationId: string; +}; + +// Backfill skips the twenty-standard and workspace-custom applications (their row is +// already provisioned by the manifest funnel) and targets every other (installed) +// application's searchable objects that have no row yet. The creation rule mirrors the +// object-create side effect handler (label-identifier field of a searchable type; +// junction/id-label objects have no search surface). Grouped by application universal +// identifier for the per-application migration run. +export const buildSearchFieldMetadataBackfillOperations = ({ + flatObjectMetadataMaps, + flatFieldMetadataMaps, + flatSearchFieldMetadataMaps, + applicationUniversalIdentifierById, + twentyStandardApplicationId, + workspaceCustomApplicationId, +}: BuildSearchFieldMetadataBackfillOperationsArgs): Record< + string, + UniversalFlatSearchFieldMetadata[] +> => { + const existingSearchFieldMetadataKeys = new Set(); + + for (const flatSearchFieldMetadata of Object.values( + flatSearchFieldMetadataMaps.byUniversalIdentifier, + ).filter(isDefined)) { + existingSearchFieldMetadataKeys.add( + `${flatSearchFieldMetadata.objectMetadataId}:${flatSearchFieldMetadata.fieldMetadataId}`, + ); + } + + const flatSearchFieldMetadatasToCreate: UniversalFlatSearchFieldMetadata[] = + []; + + for (const flatObjectMetadata of Object.values( + flatObjectMetadataMaps.byUniversalIdentifier, + ).filter(isDefined)) { + if ( + flatObjectMetadata.applicationId === twentyStandardApplicationId || + flatObjectMetadata.applicationId === workspaceCustomApplicationId + ) { + continue; + } + + if (flatObjectMetadata.isSearchable !== true) { + continue; + } + + const applicationUniversalIdentifier = + applicationUniversalIdentifierById.get(flatObjectMetadata.applicationId); + + if (!isDefined(applicationUniversalIdentifier)) { + continue; + } + + const labelIdentifierFieldMetadataUniversalIdentifier = + flatObjectMetadata.labelIdentifierFieldMetadataUniversalIdentifier; + + if (!isDefined(labelIdentifierFieldMetadataUniversalIdentifier)) { + continue; + } + + const derivedIdFieldUniversalIdentifier = getFieldUniversalIdentifier({ + applicationUniversalIdentifier, + objectUniversalIdentifier: flatObjectMetadata.universalIdentifier, + name: 'id', + }); + + if ( + labelIdentifierFieldMetadataUniversalIdentifier === + derivedIdFieldUniversalIdentifier + ) { + continue; + } + + const labelIdentifierFlatFieldMetadata = + flatFieldMetadataMaps.byUniversalIdentifier[ + labelIdentifierFieldMetadataUniversalIdentifier + ]; + + if ( + !isDefined(labelIdentifierFlatFieldMetadata) || + !isSearchableFieldType(labelIdentifierFlatFieldMetadata.type) + ) { + continue; + } + + if ( + existingSearchFieldMetadataKeys.has( + `${flatObjectMetadata.id}:${labelIdentifierFlatFieldMetadata.id}`, + ) + ) { + continue; + } + + const tsVectorFlatFieldMetadata = findTsVectorFlatFieldMetadataForObject({ + fieldUniversalIdentifiers: flatObjectMetadata.fieldUniversalIdentifiers, + flatFieldMetadataMaps, + }); + + if (!isDefined(tsVectorFlatFieldMetadata)) { + continue; + } + + flatSearchFieldMetadatasToCreate.push( + buildFlatSearchFieldMetadataForField({ + flatObjectMetadata, + flatFieldMetadata: labelIdentifierFlatFieldMetadata, + tsVectorFlatFieldMetadata, + position: 0, + }), + ); + } + + return fromArrayToValuesByKeyRecord({ + array: flatSearchFieldMetadatasToCreate, + key: 'applicationUniversalIdentifier', + }); +}; diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/2-20/utils/build-search-field-metadata-re-own-operations.util.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/2-20/utils/build-search-field-metadata-re-own-operations.util.ts new file mode 100644 index 0000000000..6d30e92cd0 --- /dev/null +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/2-20/utils/build-search-field-metadata-re-own-operations.util.ts @@ -0,0 +1,70 @@ +import { getSearchFieldUniversalIdentifier } from 'twenty-shared/application'; +import { isDefined } from 'twenty-shared/utils'; + +import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type'; +import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util'; +import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type'; +import { type FlatSearchFieldMetadata } from 'src/engine/metadata-modules/flat-search-field-metadata/types/flat-search-field-metadata.type'; + +export type SearchFieldMetadataUniversalIdentifierUpdate = { + id: string; + deterministicUniversalIdentifier: string; +}; + +type BuildSearchFieldMetadataReOwnOperationsArgs = { + flatFieldMetadataMaps: FlatEntityMaps; + flatSearchFieldMetadataMaps: FlatEntityMaps; + applicationUniversalIdentifierById: Map; +}; + +// Re-own is global (every application, including twenty-standard and workspace-custom): +// every existing searchFieldMetadata row whose universal identifier drifted from its +// getSearchFieldUniversalIdentifier derivation is converged back to it. +export const buildSearchFieldMetadataReOwnOperations = ({ + flatFieldMetadataMaps, + flatSearchFieldMetadataMaps, + applicationUniversalIdentifierById, +}: BuildSearchFieldMetadataReOwnOperationsArgs): SearchFieldMetadataUniversalIdentifierUpdate[] => { + const searchFieldMetadataUniversalIdentifierUpdates: SearchFieldMetadataUniversalIdentifierUpdate[] = + []; + + for (const flatSearchFieldMetadata of Object.values( + flatSearchFieldMetadataMaps.byUniversalIdentifier, + ).filter(isDefined)) { + const applicationUniversalIdentifier = + applicationUniversalIdentifierById.get( + flatSearchFieldMetadata.applicationId, + ); + const indexedFlatFieldMetadata = findFlatEntityByIdInFlatEntityMaps({ + flatEntityMaps: flatFieldMetadataMaps, + flatEntityId: flatSearchFieldMetadata.fieldMetadataId, + }); + + if ( + !isDefined(applicationUniversalIdentifier) || + !isDefined(indexedFlatFieldMetadata) + ) { + continue; + } + + const deterministicUniversalIdentifier = getSearchFieldUniversalIdentifier({ + applicationUniversalIdentifier, + fieldMetadataUniversalIdentifier: + indexedFlatFieldMetadata.universalIdentifier, + }); + + if ( + deterministicUniversalIdentifier === + flatSearchFieldMetadata.universalIdentifier + ) { + continue; + } + + searchFieldMetadataUniversalIdentifierUpdates.push({ + id: flatSearchFieldMetadata.id, + deterministicUniversalIdentifier, + }); + } + + return searchFieldMetadataUniversalIdentifierUpdates; +}; diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/2-20/utils/build-search-vector-gin-index-backfill-operations.util.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/2-20/utils/build-search-vector-gin-index-backfill-operations.util.ts new file mode 100644 index 0000000000..eebc185484 --- /dev/null +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/2-20/utils/build-search-vector-gin-index-backfill-operations.util.ts @@ -0,0 +1,88 @@ +import { fromArrayToValuesByKeyRecord, isDefined } from 'twenty-shared/utils'; + +import { isSearchVectorGinFlatIndexMetadata } from 'src/database/commands/upgrade-version-command/2-20/utils/is-search-vector-gin-flat-index-metadata.util'; +import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type'; +import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type'; +import { type FlatIndexMetadata } from 'src/engine/metadata-modules/flat-index-metadata/types/flat-index-metadata.type'; +import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type'; +import { findTsVectorFlatFieldMetadataForObject } from 'src/engine/metadata-modules/flat-search-field-metadata/utils/find-ts-vector-flat-field-metadata-for-object.util'; +import { buildSearchVectorGinIndexForCustomObject } from 'src/engine/metadata-modules/object-metadata/utils/build-search-vector-gin-index-for-custom-object.util'; +import { type UniversalFlatIndexMetadata } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-index-metadata.type'; + +type BuildSearchVectorGinIndexBackfillOperationsArgs = { + flatObjectMetadataMaps: FlatEntityMaps; + flatFieldMetadataMaps: FlatEntityMaps; + flatIndexMaps: FlatEntityMaps; + twentyStandardApplicationId: string; + workspaceCustomApplicationId: string; +}; + +// Backfill skips the twenty-standard and workspace-custom applications (their GIN index is +// already provisioned by the manifest funnel) and targets every other (installed) +// application object that has a searchVector field but no GIN index yet. Grouped by +// application universal identifier for the per-application migration run. +export const buildSearchVectorGinIndexBackfillOperations = ({ + flatObjectMetadataMaps, + flatFieldMetadataMaps, + flatIndexMaps, + twentyStandardApplicationId, + workspaceCustomApplicationId, +}: BuildSearchVectorGinIndexBackfillOperationsArgs): Record< + string, + UniversalFlatIndexMetadata[] +> => { + const objectMetadataIdsWithSearchVectorGinIndex = new Set(); + + for (const flatIndexMetadata of Object.values( + flatIndexMaps.byUniversalIdentifier, + ).filter(isDefined)) { + if ( + isSearchVectorGinFlatIndexMetadata({ + flatIndexMetadata, + flatFieldMetadataMaps, + }) + ) { + objectMetadataIdsWithSearchVectorGinIndex.add( + flatIndexMetadata.objectMetadataId, + ); + } + } + + const flatIndexesToCreate: UniversalFlatIndexMetadata[] = []; + + for (const flatObjectMetadata of Object.values( + flatObjectMetadataMaps.byUniversalIdentifier, + ).filter(isDefined)) { + if ( + flatObjectMetadata.applicationId === twentyStandardApplicationId || + flatObjectMetadata.applicationId === workspaceCustomApplicationId + ) { + continue; + } + + if (objectMetadataIdsWithSearchVectorGinIndex.has(flatObjectMetadata.id)) { + continue; + } + + const tsVectorFlatFieldMetadata = findTsVectorFlatFieldMetadataForObject({ + fieldUniversalIdentifiers: flatObjectMetadata.fieldUniversalIdentifiers, + flatFieldMetadataMaps, + }); + + if (!isDefined(tsVectorFlatFieldMetadata)) { + continue; + } + + flatIndexesToCreate.push( + buildSearchVectorGinIndexForCustomObject({ + flatObjectMetadata, + searchVectorFlatFieldMetadata: tsVectorFlatFieldMetadata, + }), + ); + } + + return fromArrayToValuesByKeyRecord({ + array: flatIndexesToCreate, + key: 'applicationUniversalIdentifier', + }); +}; diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/2-20/utils/build-search-vector-gin-index-re-own-operations.util.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/2-20/utils/build-search-vector-gin-index-re-own-operations.util.ts new file mode 100644 index 0000000000..0de2f2a0f7 --- /dev/null +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/2-20/utils/build-search-vector-gin-index-re-own-operations.util.ts @@ -0,0 +1,82 @@ +import { getIndexUniversalIdentifier } from 'twenty-shared/application'; +import { isDefined } from 'twenty-shared/utils'; + +import { isSearchVectorGinFlatIndexMetadata } from 'src/database/commands/upgrade-version-command/2-20/utils/is-search-vector-gin-flat-index-metadata.util'; +import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type'; +import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util'; +import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type'; +import { type FlatIndexMetadata } from 'src/engine/metadata-modules/flat-index-metadata/types/flat-index-metadata.type'; +import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type'; + +export type SearchVectorGinIndexUniversalIdentifierUpdate = { + id: string; + name: string; + deterministicUniversalIdentifier: string; +}; + +type BuildSearchVectorGinIndexReOwnOperationsArgs = { + flatObjectMetadataMaps: FlatEntityMaps; + flatFieldMetadataMaps: FlatEntityMaps; + flatIndexMaps: FlatEntityMaps; + applicationUniversalIdentifierById: Map; +}; + +// Re-own is global (every application, including twenty-standard and workspace-custom): +// every existing searchVector GIN index whose universal identifier drifted from its +// getIndexUniversalIdentifier derivation is converged back to it. +export const buildSearchVectorGinIndexReOwnOperations = ({ + flatObjectMetadataMaps, + flatFieldMetadataMaps, + flatIndexMaps, + applicationUniversalIdentifierById, +}: BuildSearchVectorGinIndexReOwnOperationsArgs): SearchVectorGinIndexUniversalIdentifierUpdate[] => { + const indexUniversalIdentifierUpdates: SearchVectorGinIndexUniversalIdentifierUpdate[] = + []; + + for (const flatIndexMetadata of Object.values( + flatIndexMaps.byUniversalIdentifier, + ).filter(isDefined)) { + if ( + !isSearchVectorGinFlatIndexMetadata({ + flatIndexMetadata, + flatFieldMetadataMaps, + }) + ) { + continue; + } + + const applicationUniversalIdentifier = + applicationUniversalIdentifierById.get(flatIndexMetadata.applicationId); + const flatObjectMetadata = findFlatEntityByIdInFlatEntityMaps({ + flatEntityMaps: flatObjectMetadataMaps, + flatEntityId: flatIndexMetadata.objectMetadataId, + }); + + if ( + !isDefined(applicationUniversalIdentifier) || + !isDefined(flatObjectMetadata) + ) { + continue; + } + + const deterministicUniversalIdentifier = getIndexUniversalIdentifier({ + applicationUniversalIdentifier, + objectUniversalIdentifier: flatObjectMetadata.universalIdentifier, + name: flatIndexMetadata.name, + }); + + if ( + deterministicUniversalIdentifier === flatIndexMetadata.universalIdentifier + ) { + continue; + } + + indexUniversalIdentifierUpdates.push({ + id: flatIndexMetadata.id, + name: flatIndexMetadata.name, + deterministicUniversalIdentifier, + }); + } + + return indexUniversalIdentifierUpdates; +}; diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/2-20/utils/is-search-vector-gin-flat-index-metadata.util.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/2-20/utils/is-search-vector-gin-flat-index-metadata.util.ts new file mode 100644 index 0000000000..6e5eb79b87 --- /dev/null +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/2-20/utils/is-search-vector-gin-flat-index-metadata.util.ts @@ -0,0 +1,38 @@ +import { FieldMetadataType } from 'twenty-shared/types'; +import { isDefined } from 'twenty-shared/utils'; + +import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type'; +import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util'; +import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type'; +import { type FlatIndexMetadata } from 'src/engine/metadata-modules/flat-index-metadata/types/flat-index-metadata.type'; +import { IndexType } from 'src/engine/metadata-modules/index-metadata/types/indexType.types'; +import { SEARCH_VECTOR_FIELD } from 'src/engine/metadata-modules/search-field-metadata/constants/search-vector-field.constants'; + +// The searchVector GIN index is the single-column GIN index whose only field is +// the object's TS_VECTOR searchVector field (see build-search-vector-gin-index-for-custom-object.util). +export const isSearchVectorGinFlatIndexMetadata = ({ + flatIndexMetadata, + flatFieldMetadataMaps, +}: { + flatIndexMetadata: FlatIndexMetadata; + flatFieldMetadataMaps: FlatEntityMaps; +}): boolean => { + if (flatIndexMetadata.indexType !== IndexType.GIN) { + return false; + } + + if (flatIndexMetadata.flatIndexFieldMetadatas.length !== 1) { + return false; + } + + const indexedFlatFieldMetadata = findFlatEntityByIdInFlatEntityMaps({ + flatEntityMaps: flatFieldMetadataMaps, + flatEntityId: flatIndexMetadata.flatIndexFieldMetadatas[0].fieldMetadataId, + }); + + return ( + isDefined(indexedFlatFieldMetadata) && + indexedFlatFieldMetadata.type === FieldMetadataType.TS_VECTOR && + indexedFlatFieldMetadata.name === SEARCH_VECTOR_FIELD.name + ); +}; diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/instance-commands.constant.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/instance-commands.constant.ts index 5678b75465..08160fa931 100644 --- a/packages/twenty-server/src/database/commands/upgrade-version-command/instance-commands.constant.ts +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/instance-commands.constant.ts @@ -101,7 +101,9 @@ import { BackfillDisplayFieldsOnApplicationRegistrationSlowInstanceCommand } fro import { BackfillIsFeaturedOnApplicationRegistrationSlowInstanceCommand } from './2-19/2-19-instance-command-slow-1783120000000-backfill-is-featured-on-application-registration'; import { AllowServerScopedFileFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-20/2-20-instance-command-fast-1783499671541-allow-server-scoped-file'; import { CreateWorkflowVersionCoreTableFastInstanceCommand } from './2-20/2-20-instance-command-fast-1783512000000-create-workflow-version-core-table'; +import { BackfillNameFieldIsSystemSideEffectSlowInstanceCommand } from './2-20/2-20-instance-command-slow-1783529458168-backfill-name-field-is-system-side-effect'; import { RenameIsFeaturedToIsVettedOnApplicationRegistrationFastInstanceCommand } from './2-20/2-20-instance-command-fast-1783527064000-rename-is-featured-to-is-vetted-on-application-registration'; +import { AddIsSystemSideEffectToSearchFieldMetadataFastInstanceCommand } from './2-20/2-20-instance-command-fast-1783580127637-add-is-system-side-effect-to-search-field-metadata'; export const INSTANCE_COMMANDS = [ AddViewFieldGroupIdIndexOnViewFieldFastInstanceCommand, @@ -205,5 +207,7 @@ export const INSTANCE_COMMANDS = [ BackfillIsFeaturedOnApplicationRegistrationSlowInstanceCommand, AllowServerScopedFileFastInstanceCommand, CreateWorkflowVersionCoreTableFastInstanceCommand, + BackfillNameFieldIsSystemSideEffectSlowInstanceCommand, RenameIsFeaturedToIsVettedOnApplicationRegistrationFastInstanceCommand, + AddIsSystemSideEffectToSearchFieldMetadataFastInstanceCommand, ]; diff --git a/packages/twenty-server/src/engine/core-modules/application/application-manifest/converters/from-field-manifest-to-universal-flat-field-metadata.util.ts b/packages/twenty-server/src/engine/core-modules/application/application-manifest/converters/from-field-manifest-to-universal-flat-field-metadata.util.ts index 99798f8b85..873b610876 100644 --- a/packages/twenty-server/src/engine/core-modules/application/application-manifest/converters/from-field-manifest-to-universal-flat-field-metadata.util.ts +++ b/packages/twenty-server/src/engine/core-modules/application/application-manifest/converters/from-field-manifest-to-universal-flat-field-metadata.util.ts @@ -9,8 +9,8 @@ import { ApplicationExceptionCode, } from 'src/engine/core-modules/application/application.exception'; import { type CompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/types/composite-field-metadata-type.type'; -import { isCompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util'; import { generateDefaultValue } from 'src/engine/metadata-modules/field-metadata/utils/generate-default-value'; +import { isCompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util'; import { nullifyEmptyCompositeDefaultValue } from 'src/engine/metadata-modules/flat-field-metadata/utils/nullify-empty-composite-default-value.util'; import { PARTIAL_SYSTEM_FLAT_FIELD_METADATAS } from 'src/engine/metadata-modules/object-metadata/constants/partial-system-flat-field-metadatas.constant'; import { isMorphOrRelationFieldMetadataType } from 'src/engine/utils/is-morph-or-relation-field-metadata-type.util'; diff --git a/packages/twenty-server/src/engine/core-modules/application/application-manifest/utils/build-all-flat-entity-operation-record-by-metadata-name-from-from-to.util.ts b/packages/twenty-server/src/engine/core-modules/application/application-manifest/utils/build-all-flat-entity-operation-record-by-metadata-name-from-from-to.util.ts index e4d2817b0d..8061d9856e 100644 --- a/packages/twenty-server/src/engine/core-modules/application/application-manifest/utils/build-all-flat-entity-operation-record-by-metadata-name-from-from-to.util.ts +++ b/packages/twenty-server/src/engine/core-modules/application/application-manifest/utils/build-all-flat-entity-operation-record-by-metadata-name-from-from-to.util.ts @@ -11,7 +11,7 @@ import { } from 'src/engine/metadata-modules/flat-entity/types/all-flat-entity-operation-record-by-metadata-name.type'; import { type MetadataUniversalFlatEntity } from 'src/engine/metadata-modules/flat-entity/types/metadata-universal-flat-entity.type'; import { getMetadataFlatEntityMapsKey } from 'src/engine/metadata-modules/flat-entity/utils/get-metadata-flat-entity-maps-key.util'; -import { isSystemUniqueFlatIndexMetadata } from 'src/engine/metadata-modules/flat-index-metadata/utils/is-system-unique-flat-index-metadata.util'; +import { isSystemSideEffectFlatEntity } from 'src/engine/metadata-modules/flat-entity/utils/is-system-side-effect-flat-entity.util'; import { type MetadataUniversalFlatEntityMaps } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/metadata-universal-flat-entity-maps.type'; import { compareTwoFlatEntity } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/utils/compare-two-universal-flat-entity.util'; import { shouldInferDeletionFromMissingEntities } from 'src/engine/workspace-manager/workspace-migration/utils/should-infer-deletion-from-missing-entities.util'; @@ -60,18 +60,12 @@ const buildFlatEntityOperationRecordForMetadata = ({ toByUniversalIdentifier[fromFlatEntity.universalIdentifier], ), ) - .filter((fromFlatEntity) => { - if (metadataName !== ALL_METADATA_NAME.index) { - return true; - } - - return !isSystemUniqueFlatIndexMetadata( - fromFlatEntity as unknown as { - isSystemSideEffect: boolean; - isUnique: boolean; - }, - ); - }) + .filter( + (fromFlatEntity) => + !isSystemSideEffectFlatEntity( + fromFlatEntity as unknown as MetadataUniversalFlatEntity, + ), + ) : []; const flatEntityToUpdate = Object.values(fromByUniversalIdentifier) diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/services/field-metadata.service.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/services/field-metadata.service.ts index 4b147bc02e..11925c8c25 100644 --- a/packages/twenty-server/src/engine/metadata-modules/field-metadata/services/field-metadata.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/services/field-metadata.service.ts @@ -94,7 +94,6 @@ export class FieldMetadataService extends TypeOrmQueryService 0 ? { pageLayoutWidget: { diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-entity-properties-configuration-by-metadata-name.constant.ts b/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-entity-properties-configuration-by-metadata-name.constant.ts index 797d0022c7..546b91f748 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-entity-properties-configuration-by-metadata-name.constant.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-entity-properties-configuration-by-metadata-name.constant.ts @@ -1807,6 +1807,11 @@ export const ALL_ENTITY_PROPERTIES_CONFIGURATION_BY_METADATA_NAME = { }, }, searchFieldMetadata: { + isSystemSideEffect: { + toCompare: false, + toStringify: false, + universalProperty: undefined, + }, objectMetadataId: { toCompare: false, toStringify: false, diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-metadata-side-effect-companion-metadata-names.constant.ts b/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-metadata-side-effect-companion-metadata-names.constant.ts index 36c71c76be..f85ab9759b 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-metadata-side-effect-companion-metadata-names.constant.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-metadata-side-effect-companion-metadata-names.constant.ts @@ -1,7 +1,8 @@ import { type AllMetadataName } from 'twenty-shared/metadata'; export const ALL_METADATA_SIDE_EFFECT_COMPANION_METADATA_NAMES = { - fieldMetadata: ['index'], + fieldMetadata: ['index', 'searchFieldMetadata'], + objectMetadata: ['fieldMetadata', 'index', 'searchFieldMetadata'], } as const satisfies Partial< Record >; diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/__tests__/from-delete-field-input-to-flat-field-metadatas-to-delete.spec.ts b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/__tests__/from-delete-field-input-to-flat-field-metadatas-to-delete.spec.ts index b1b577cdbb..50ba10436c 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/__tests__/from-delete-field-input-to-flat-field-metadatas-to-delete.spec.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/__tests__/from-delete-field-input-to-flat-field-metadatas-to-delete.spec.ts @@ -46,7 +46,6 @@ describe('fromDeleteFieldInputToFlatFieldMetadatasToDelete', () => { flatFieldMetadataMaps: buildFlatFieldMetadataMaps([]), flatObjectMetadataMaps: buildFlatObjectMetadataMaps('obj-1'), flatIndexMaps: createEmptyFlatEntityMaps(), - flatSearchFieldMetadataMaps: createEmptyFlatEntityMaps(), }), ).toThrow( expect.objectContaining({ @@ -70,7 +69,6 @@ describe('fromDeleteFieldInputToFlatFieldMetadatasToDelete', () => { flatFieldMetadataMaps: buildFlatFieldMetadataMaps([standardField]), flatObjectMetadataMaps: buildFlatObjectMetadataMaps(objectId), flatIndexMaps: createEmptyFlatEntityMaps(), - flatSearchFieldMetadataMaps: createEmptyFlatEntityMaps(), }), ).toThrow( expect.objectContaining({ @@ -94,7 +92,6 @@ describe('fromDeleteFieldInputToFlatFieldMetadatasToDelete', () => { flatFieldMetadataMaps: buildFlatFieldMetadataMaps([standardField]), flatObjectMetadataMaps: buildFlatObjectMetadataMaps(objectId), flatIndexMaps: createEmptyFlatEntityMaps(), - flatSearchFieldMetadataMaps: createEmptyFlatEntityMaps(), }), ).toThrow(new RegExp('Cannot delete standard field "city"')); }); @@ -113,7 +110,6 @@ describe('fromDeleteFieldInputToFlatFieldMetadatasToDelete', () => { flatFieldMetadataMaps: buildFlatFieldMetadataMaps([customField]), flatObjectMetadataMaps: buildFlatObjectMetadataMaps(objectId), flatIndexMaps: createEmptyFlatEntityMaps(), - flatSearchFieldMetadataMaps: createEmptyFlatEntityMaps(), }); expect(result.flatFieldMetadatasToDelete).toContainEqual( @@ -136,7 +132,6 @@ describe('fromDeleteFieldInputToFlatFieldMetadatasToDelete', () => { flatFieldMetadataMaps: buildFlatFieldMetadataMaps([standardField]), flatObjectMetadataMaps: buildFlatObjectMetadataMaps(objectId), flatIndexMaps: createEmptyFlatEntityMaps(), - flatSearchFieldMetadataMaps: createEmptyFlatEntityMaps(), }), ).toThrow(FieldMetadataException); }); @@ -160,7 +155,6 @@ describe('fromDeleteFieldInputToFlatFieldMetadatasToDelete', () => { flatFieldMetadataMaps: buildFlatFieldMetadataMaps([standardAppField]), flatObjectMetadataMaps: buildFlatObjectMetadataMaps(objectId), flatIndexMaps: createEmptyFlatEntityMaps(), - flatSearchFieldMetadataMaps: createEmptyFlatEntityMaps(), }), ).toThrow( expect.objectContaining({ diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/compute-search-field-metadata-deletion-for-deleted-fields.util.ts b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/compute-search-field-metadata-deletion-for-deleted-fields.util.ts deleted file mode 100644 index 19bbedb87c..0000000000 --- a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/compute-search-field-metadata-deletion-for-deleted-fields.util.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { isDefined } from 'twenty-shared/utils'; - -import { type AllFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/all-flat-entity-maps.type'; -import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util'; -import { findManyFlatEntityByIdInFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-many-flat-entity-by-id-in-flat-entity-maps-or-throw.util'; -import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type'; -import { type FlatSearchFieldMetadata } from 'src/engine/metadata-modules/flat-search-field-metadata/types/flat-search-field-metadata.type'; -import { type UniversalFlatSearchFieldMetadata } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-search-field-metadata.type'; - -export const computeSearchFieldMetadataDeletionForDeletedFields = ({ - flatFieldMetadatasToDelete, - flatObjectMetadataMaps, - flatSearchFieldMetadataMaps, -}: { - flatFieldMetadatasToDelete: FlatFieldMetadata[]; -} & Pick< - AllFlatEntityMaps, - 'flatObjectMetadataMaps' | 'flatSearchFieldMetadataMaps' ->): { - searchFieldMetadatasToDelete: UniversalFlatSearchFieldMetadata[]; -} => { - const deletedFieldIds = new Set( - flatFieldMetadatasToDelete.map((flatFieldMetadata) => flatFieldMetadata.id), - ); - const affectedObjectMetadataIds = new Set( - flatFieldMetadatasToDelete.map( - (flatFieldMetadata) => flatFieldMetadata.objectMetadataId, - ), - ); - - const searchFieldMetadatasToDelete: UniversalFlatSearchFieldMetadata[] = []; - - for (const objectMetadataId of affectedObjectMetadataIds) { - const flatObjectMetadata = findFlatEntityByIdInFlatEntityMaps({ - flatEntityId: objectMetadataId, - flatEntityMaps: flatObjectMetadataMaps, - }); - - if (!isDefined(flatObjectMetadata) || !flatObjectMetadata.isSearchable) { - continue; - } - - const objectSearchFieldMetadatas = - findManyFlatEntityByIdInFlatEntityMapsOrThrow({ - flatEntityMaps: flatSearchFieldMetadataMaps, - flatEntityIds: flatObjectMetadata.searchFieldMetadataIds, - }); - - const rowsToDelete = objectSearchFieldMetadatas.filter( - (searchFieldMetadata) => - deletedFieldIds.has(searchFieldMetadata.fieldMetadataId), - ); - - searchFieldMetadatasToDelete.push(...rowsToDelete); - } - - return { - searchFieldMetadatasToDelete, - }; -}; diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/from-delete-field-input-to-flat-field-metadatas-to-delete.util.ts b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/from-delete-field-input-to-flat-field-metadatas-to-delete.util.ts index f628cdaa22..57cf61bfb8 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/from-delete-field-input-to-flat-field-metadatas-to-delete.util.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/from-delete-field-input-to-flat-field-metadatas-to-delete.util.ts @@ -13,7 +13,6 @@ import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/ import { findFlatEntityByUniversalIdentifierOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-universal-identifier-or-throw.util'; import { findManyFlatEntityByUniversalIdentifierInUniversalFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-many-flat-entity-by-universal-identifier-in-universal-flat-entity-maps-or-throw.util'; import { computeFlatFieldMetadataRelatedFlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/utils/compute-flat-field-metadata-related-flat-field-metadata.util'; -import { computeSearchFieldMetadataDeletionForDeletedFields } from 'src/engine/metadata-modules/flat-field-metadata/utils/compute-search-field-metadata-deletion-for-deleted-fields.util'; import { isMorphOrRelationFlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/utils/is-morph-or-relation-flat-field-metadata.util'; import { type FlatIndexMetadata } from 'src/engine/metadata-modules/flat-index-metadata/types/flat-index-metadata.type'; import { isSystemUniqueFlatIndexMetadata } from 'src/engine/metadata-modules/flat-index-metadata/utils/is-system-unique-flat-index-metadata.util'; @@ -21,16 +20,12 @@ import { generateFlatIndexMetadataWithNameOrThrow } from 'src/engine/metadata-mo import { belongsToTwentyStandardApp } from 'src/engine/metadata-modules/utils/belongs-to-twenty-standard-app.util'; import { type UniversalFlatFieldMetadata } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-field-metadata.type'; import { type UniversalFlatIndexMetadata } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-index-metadata.type'; -import { type UniversalFlatSearchFieldMetadata } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-search-field-metadata.type'; type FromDeleteFieldInputToFlatFieldMetadatasToDeleteArgs = { deleteOneFieldInput: DeleteOneFieldInput; } & Pick< AllFlatEntityMaps, - | 'flatFieldMetadataMaps' - | 'flatIndexMaps' - | 'flatObjectMetadataMaps' - | 'flatSearchFieldMetadataMaps' + 'flatFieldMetadataMaps' | 'flatIndexMaps' | 'flatObjectMetadataMaps' >; // TODO refactor as a side effect service export const fromDeleteFieldInputToFlatFieldMetadatasToDelete = ({ @@ -38,12 +33,10 @@ export const fromDeleteFieldInputToFlatFieldMetadatasToDelete = ({ deleteOneFieldInput: rawDeleteOneInput, flatIndexMaps: existingFlatIndexMaps, flatFieldMetadataMaps: existingFlatFieldMetadataMaps, - flatSearchFieldMetadataMaps: existingFlatSearchFieldMetadataMaps, }: FromDeleteFieldInputToFlatFieldMetadatasToDeleteArgs): { flatFieldMetadatasToDelete: UniversalFlatFieldMetadata[]; flatIndexesToUpdate: UniversalFlatIndexMetadata[]; flatIndexesToDelete: UniversalFlatIndexMetadata[]; - searchFieldMetadatasToDelete: UniversalFlatSearchFieldMetadata[]; } => { const { id: fieldMetadataToDeleteId } = trimAndRemoveDuplicatedWhitespacesFromObjectStringProperties( @@ -214,17 +207,9 @@ export const fromDeleteFieldInputToFlatFieldMetadatasToDelete = ({ }, ); - const { searchFieldMetadatasToDelete } = - computeSearchFieldMetadataDeletionForDeletedFields({ - flatFieldMetadatasToDelete, - flatObjectMetadataMaps: existingFlatObjectMetadataMaps, - flatSearchFieldMetadataMaps: existingFlatSearchFieldMetadataMaps, - }); - return { flatFieldMetadatasToDelete, flatIndexesToDelete, flatIndexesToUpdate, - searchFieldMetadatasToDelete, }; }; diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-object-metadata/utils/from-create-object-input-to-flat-object-metadata-and-flat-field-metadatas-to-create.util.ts b/packages/twenty-server/src/engine/metadata-modules/flat-object-metadata/utils/from-create-object-input-to-flat-object-metadata-and-flat-field-metadatas-to-create.util.ts index 12b5b6f10d..1a393692a8 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-object-metadata/utils/from-create-object-input-to-flat-object-metadata-and-flat-field-metadatas-to-create.util.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-object-metadata/utils/from-create-object-input-to-flat-object-metadata-and-flat-field-metadatas-to-create.util.ts @@ -1,5 +1,7 @@ +import { getFieldUniversalIdentifier } from 'twenty-shared/application'; import { capitalize, + isDefined, trimAndRemoveDuplicatedWhitespacesFromObjectStringProperties, } from 'twenty-shared/utils'; import { v4 } from 'uuid'; @@ -7,20 +9,18 @@ import { v4 } from 'uuid'; import { type FlatApplication } from 'src/engine/core-modules/application/types/flat-application.type'; import { type AllFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/all-flat-entity-maps.type'; import { type CreateObjectInput } from 'src/engine/metadata-modules/object-metadata/dtos/create-object.input'; -import { buildDefaultFlatFieldMetadatasForCustomObject } from 'src/engine/metadata-modules/object-metadata/utils/build-default-flat-field-metadatas-for-custom-object.util'; -import { buildDefaultIndexesForCustomObject } from 'src/engine/metadata-modules/object-metadata/utils/build-default-index-for-custom-object.util'; +import { buildNameFlatFieldMetadataForCustomObject } from 'src/engine/metadata-modules/object-metadata/utils/build-name-flat-field-metadata-for-custom-object.util'; import { buildDefaultRelationFlatFieldMetadatasForCustomObject } from 'src/engine/metadata-modules/object-metadata/utils/build-default-relation-flat-field-metadatas-for-custom-object.util'; -import { buildDefaultSearchFieldMetadatasForCustomObject } from 'src/engine/metadata-modules/object-metadata/utils/build-default-search-field-metadatas-for-custom-object.util'; import { type UniversalFlatFieldMetadata } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-field-metadata.type'; import { type UniversalFlatIndexMetadata } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-index-metadata.type'; import { type UniversalFlatObjectMetadata } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-object-metadata.type'; -import { type UniversalFlatSearchFieldMetadata } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-search-field-metadata.type'; type FromCreateObjectInputToFlatObjectMetadataAndFlatFieldMetadatasToCreateArgs = { createObjectInput: CreateObjectInput; flatApplication: FlatApplication; } & Pick; + export const fromCreateObjectInputToFlatObjectMetadataAndFlatFieldMetadatasToCreate = ({ createObjectInput: rawCreateObjectInput, @@ -28,10 +28,9 @@ export const fromCreateObjectInputToFlatObjectMetadataAndFlatFieldMetadatasToCre flatObjectMetadataMaps: existingFlatObjectMetadataMaps, }: FromCreateObjectInputToFlatObjectMetadataAndFlatFieldMetadatasToCreateArgs): { flatObjectMetadataToCreate: UniversalFlatObjectMetadata & { id: string }; - relationTargetFlatFieldMetadataToCreate: UniversalFlatFieldMetadata[]; flatFieldMetadataToCreateOnObject: UniversalFlatFieldMetadata[]; + relationTargetFlatFieldMetadataToCreate: UniversalFlatFieldMetadata[]; flatIndexMetadataToCreate: UniversalFlatIndexMetadata[]; - flatSearchFieldMetadataToCreate: UniversalFlatSearchFieldMetadata[]; } => { const createObjectInput = trimAndRemoveDuplicatedWhitespacesFromObjectStringProperties( @@ -49,21 +48,14 @@ export const fromCreateObjectInputToFlatObjectMetadataAndFlatFieldMetadatasToCre const objectMetadataId = v4(); const universalIdentifier = createObjectInput.universalIdentifier ?? v4(); - const defaultFlatFieldForCustomObjectMaps = - buildDefaultFlatFieldMetadatasForCustomObject({ - flatObjectMetadata: { - applicationUniversalIdentifier: flatApplication.universalIdentifier, - universalIdentifier, - }, - skipNameField: createObjectInput.skipNameField, - }); const createdAt = new Date().toISOString(); - // Use nameField.id if it exists, otherwise use idField.id (for junction tables without name) - const nameField = defaultFlatFieldForCustomObjectMaps.fields.nameField; const labelIdentifierFieldMetadataUniversalIdentifier = - nameField?.universalIdentifier ?? - defaultFlatFieldForCustomObjectMaps.fields.id.universalIdentifier; + getFieldUniversalIdentifier({ + applicationUniversalIdentifier: flatApplication.universalIdentifier, + objectUniversalIdentifier: universalIdentifier, + name: createObjectInput.skipNameField ? 'id' : 'name', + }); const universalFlatObjectMetadataToCreate: UniversalFlatObjectMetadata & { id: string; @@ -102,6 +94,17 @@ export const fromCreateObjectInputToFlatObjectMetadataAndFlatFieldMetadatasToCre imageIdentifierFieldMetadataUniversalIdentifier: null, }; + const nameFlatFieldMetadata = + createObjectInput.skipNameField === true + ? null + : buildNameFlatFieldMetadataForCustomObject({ + flatObjectMetadata: { + applicationUniversalIdentifier: + flatApplication.universalIdentifier, + universalIdentifier, + }, + }); + const { standardSourceFlatFieldMetadatas, standardTargetFlatFieldMetadatas, @@ -112,32 +115,15 @@ export const fromCreateObjectInputToFlatObjectMetadataAndFlatFieldMetadatasToCre flatApplication, }); - const objectFlatFieldMetadatas: UniversalFlatFieldMetadata[] = [ - ...Object.values(defaultFlatFieldForCustomObjectMaps.fields), + const flatFieldMetadataToCreateOnObject: UniversalFlatFieldMetadata[] = [ + ...(isDefined(nameFlatFieldMetadata) ? [nameFlatFieldMetadata] : []), ...standardSourceFlatFieldMetadatas, ]; - const defaultIndexesForCustomObject = buildDefaultIndexesForCustomObject({ - objectFlatFieldMetadatas, - defaultFlatFieldForCustomObjectMaps, - flatObjectMetadata: universalFlatObjectMetadataToCreate, - }); - - const defaultSearchFieldMetadatasForCustomObject = - buildDefaultSearchFieldMetadatasForCustomObject({ - defaultFlatFieldForCustomObjectMaps, - flatObjectMetadata: universalFlatObjectMetadataToCreate, - }); - return { flatObjectMetadataToCreate: universalFlatObjectMetadataToCreate, - flatIndexMetadataToCreate: [ - ...Object.values(defaultIndexesForCustomObject.indexes), - ...standardTargetFlatIndexMetadatas, - ], - flatSearchFieldMetadataToCreate: - defaultSearchFieldMetadatasForCustomObject.searchFieldMetadatas, + flatFieldMetadataToCreateOnObject, relationTargetFlatFieldMetadataToCreate: standardTargetFlatFieldMetadatas, - flatFieldMetadataToCreateOnObject: objectFlatFieldMetadatas, + flatIndexMetadataToCreate: standardTargetFlatIndexMetadatas, }; }; diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-object-metadata/validators/utils/validate-object-metadata-cross-entity.util.ts b/packages/twenty-server/src/engine/metadata-modules/flat-object-metadata/validators/utils/validate-object-metadata-cross-entity.util.ts index 9c26022860..1b724441e1 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-object-metadata/validators/utils/validate-object-metadata-cross-entity.util.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-object-metadata/validators/utils/validate-object-metadata-cross-entity.util.ts @@ -3,7 +3,6 @@ import { isDefined } from 'twenty-shared/utils'; import { findFlatEntityByUniversalIdentifierOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-universal-identifier-or-throw.util'; import { findManyFlatEntityByUniversalIdentifierInUniversalFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-many-flat-entity-by-universal-identifier-in-universal-flat-entity-maps-or-throw.util'; import { validateFlatObjectMetadataIdentifiers } from 'src/engine/metadata-modules/flat-object-metadata/validators/utils/validate-flat-object-metadata-identifiers.util'; -import { validateObjectMetadataSystemFieldsIntegrity } from 'src/engine/metadata-modules/flat-object-metadata/validators/utils/validate-object-metadata-system-fields-integrity.util'; import { type OrchestratorActionsReport, type OrchestratorFailureReport, @@ -57,14 +56,6 @@ export const validateObjectMetadataCrossEntity = ({ }, ); - createFailedFlatEntityValidations.errors.push( - ...validateObjectMetadataSystemFieldsIntegrity({ - universalFlatFieldMetadataMaps: - optimisticUniversalFlatMaps.flatFieldMetadataMaps, - universalFlatObjectMetadata, - }), - ); - createFailedFlatEntityValidations.errors.push( ...validateFlatObjectMetadataIdentifiers({ universalFlatObjectMetadata, diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-object-metadata/validators/utils/validate-object-metadata-system-fields-integrity.util.ts b/packages/twenty-server/src/engine/metadata-modules/flat-object-metadata/validators/utils/validate-object-metadata-system-fields-integrity.util.ts deleted file mode 100644 index 03190f6fa0..0000000000 --- a/packages/twenty-server/src/engine/metadata-modules/flat-object-metadata/validators/utils/validate-object-metadata-system-fields-integrity.util.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { msg } from '@lingui/core/macro'; -import { getFieldUniversalIdentifier } from 'twenty-shared/application'; -import { isDefined } from 'twenty-shared/utils'; - -import { findFlatEntityByUniversalIdentifierOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-universal-identifier-or-throw.util'; -import { PARTIAL_SYSTEM_FLAT_FIELD_METADATAS } from 'src/engine/metadata-modules/object-metadata/constants/partial-system-flat-field-metadatas.constant'; -import { ObjectMetadataExceptionCode } from 'src/engine/metadata-modules/object-metadata/object-metadata.exception'; -import { type AllUniversalFlatEntityMaps } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/all-universal-flat-entity-maps.type'; -import { type UniversalFlatFieldMetadata } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-field-metadata.type'; -import { type UniversalFlatObjectMetadata } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-object-metadata.type'; -import { type FlatEntityValidationError } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/types/failed-flat-entity-validation.type'; -import { buildUniversalFlatObjectFieldByNameAndJoinColumnMaps } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/utils/build-universal-flat-object-field-by-name-and-join-column-maps.util'; - -type ValidateObjectMetadataSystemFieldsIntegrityArgs = { - universalFlatObjectMetadata: UniversalFlatObjectMetadata; - universalFlatFieldMetadataMaps: AllUniversalFlatEntityMaps['flatFieldMetadataMaps']; -}; -export const validateObjectMetadataSystemFieldsIntegrity = ({ - universalFlatFieldMetadataMaps, - universalFlatObjectMetadata, -}: ValidateObjectMetadataSystemFieldsIntegrityArgs): FlatEntityValidationError[] => { - const errors: FlatEntityValidationError[] = []; - - const { fieldUniversalIdentifierByName } = - buildUniversalFlatObjectFieldByNameAndJoinColumnMaps({ - flatFieldMetadataMaps: universalFlatFieldMetadataMaps, - flatObjectMetadata: universalFlatObjectMetadata, - }); - - for (const expectedSystemField of Object.values( - PARTIAL_SYSTEM_FLAT_FIELD_METADATAS, - )) { - const matchingFieldUniversalIdentifier = - fieldUniversalIdentifierByName[expectedSystemField.name]; - - const expectedFieldName = expectedSystemField.name; - - if (!isDefined(matchingFieldUniversalIdentifier)) { - errors.push({ - code: ObjectMetadataExceptionCode.MISSING_SYSTEM_FIELD, - message: `System field ${expectedFieldName} is missing`, - userFriendlyMessage: msg`System field ${expectedFieldName} is missing`, - value: expectedFieldName, - }); - } else { - const universalFlatFieldMetadata = - findFlatEntityByUniversalIdentifierOrThrow({ - flatEntityMaps: universalFlatFieldMetadataMaps, - universalIdentifier: matchingFieldUniversalIdentifier, - }); - - const propertiesToValidate = [ - 'type', - 'isSystem', - ] as const satisfies (keyof UniversalFlatFieldMetadata)[]; - - for (const property of propertiesToValidate) { - const expectedValue = expectedSystemField[property]; - const actualValue = universalFlatFieldMetadata[property]; - - if (actualValue !== expectedValue) { - errors.push({ - code: ObjectMetadataExceptionCode.INVALID_SYSTEM_FIELD, - message: `System field ${expectedFieldName} has invalid ${property}: expected ${String(expectedValue)}, got ${String(actualValue)}`, - userFriendlyMessage: msg`System field ${expectedFieldName} has invalid ${property}`, - value: actualValue, - }); - } - } - - // System field universal identifiers are server-owned and must match - // the deterministic derivation; clients cannot provide custom ones. - // TODO: remove once system fields are generated server side only by - // the metadata side effect engine and stripped from client inputs. - const expectedUniversalIdentifier = getFieldUniversalIdentifier({ - applicationUniversalIdentifier: - universalFlatObjectMetadata.applicationUniversalIdentifier, - objectUniversalIdentifier: - universalFlatObjectMetadata.universalIdentifier, - name: expectedFieldName, - }); - - if ( - universalFlatFieldMetadata.universalIdentifier !== - expectedUniversalIdentifier - ) { - errors.push({ - code: ObjectMetadataExceptionCode.INVALID_SYSTEM_FIELD, - message: `System field ${expectedFieldName} has invalid universalIdentifier: expected ${expectedUniversalIdentifier}, got ${universalFlatFieldMetadata.universalIdentifier}`, - userFriendlyMessage: msg`System field ${expectedFieldName} universal identifier is not deterministic; it is derived by the server and cannot be customized`, - value: universalFlatFieldMetadata.universalIdentifier, - }); - } - } - } - - return errors; -}; diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-search-field-metadata/utils/build-flat-search-field-metadata-for-field.util.ts b/packages/twenty-server/src/engine/metadata-modules/flat-search-field-metadata/utils/build-flat-search-field-metadata-for-field.util.ts index eef9ff9192..5f7b209593 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-search-field-metadata/utils/build-flat-search-field-metadata-for-field.util.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-search-field-metadata/utils/build-flat-search-field-metadata-for-field.util.ts @@ -1,4 +1,4 @@ -import { v4 } from 'uuid'; +import { getSearchFieldUniversalIdentifier } from 'twenty-shared/application'; import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type'; import { type UniversalFlatSearchFieldMetadata } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-search-field-metadata.type'; @@ -20,10 +20,15 @@ export const buildFlatSearchFieldMetadataForField = ({ const createdAt = new Date().toISOString(); return { - universalIdentifier: v4(), + universalIdentifier: getSearchFieldUniversalIdentifier({ + applicationUniversalIdentifier: + flatObjectMetadata.applicationUniversalIdentifier, + fieldMetadataUniversalIdentifier: flatFieldMetadata.universalIdentifier, + }), createdAt, updatedAt: createdAt, position, + isSystemSideEffect: true, applicationUniversalIdentifier: flatObjectMetadata.applicationUniversalIdentifier, objectMetadataUniversalIdentifier: flatObjectMetadata.universalIdentifier, diff --git a/packages/twenty-server/src/engine/metadata-modules/metadata-side-effect/handlers/field-metadata/services/__tests__/field-search-field-metadata-on-delete-side-effect-handler.service.spec.ts b/packages/twenty-server/src/engine/metadata-modules/metadata-side-effect/handlers/field-metadata/services/__tests__/field-search-field-metadata-on-delete-side-effect-handler.service.spec.ts new file mode 100644 index 0000000000..a773f1251c --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/metadata-side-effect/handlers/field-metadata/services/__tests__/field-search-field-metadata-on-delete-side-effect-handler.service.spec.ts @@ -0,0 +1,102 @@ +import { type BuildSideEffectsArgs } from 'src/engine/metadata-modules/metadata-side-effect/interfaces/base-metadata-side-effect-handler.service'; +import { FieldSearchFieldMetadataOnDeleteSideEffectHandlerService } from 'src/engine/metadata-modules/metadata-side-effect/handlers/field-metadata/services/field-search-field-metadata-on-delete-side-effect-handler.service'; + +const DELETED_FIELD_UNIVERSAL_IDENTIFIER = + 'd1d2d3d4-d5d6-4000-8000-000000000001'; +const OTHER_FIELD_UNIVERSAL_IDENTIFIER = 'd1d2d3d4-d5d6-4000-8000-000000000002'; +const SEARCH_FIELD_METADATA_UNIVERSAL_IDENTIFIER = + 'f1f2f3f4-f5f6-4000-8000-000000000001'; +const OTHER_SEARCH_FIELD_METADATA_UNIVERSAL_IDENTIFIER = + 'f1f2f3f4-f5f6-4000-8000-000000000002'; + +const buildArgs = ({ + searchFieldMetadataUniversalIdentifiers, + relatedFlatEntityMaps, +}: { + searchFieldMetadataUniversalIdentifiers: string[]; + relatedFlatEntityMaps: object; +}): BuildSideEffectsArgs<'fieldMetadata'> => + ({ + flatEntity: { + universalIdentifier: DELETED_FIELD_UNIVERSAL_IDENTIFIER, + searchFieldMetadataUniversalIdentifiers, + }, + allFlatEntityOperationRecordByMetadataName: {}, + relatedFlatEntityMaps, + context: {}, + }) as unknown as BuildSideEffectsArgs<'fieldMetadata'>; + +describe('FieldSearchFieldMetadataOnDeleteSideEffectHandlerService', () => { + const handler = + new (FieldSearchFieldMetadataOnDeleteSideEffectHandlerService as unknown as new () => FieldSearchFieldMetadataOnDeleteSideEffectHandlerService)(); + + it('should cascade-delete every searchFieldMetadata row indexing the deleted field', () => { + const result = handler.buildSideEffects( + buildArgs({ + searchFieldMetadataUniversalIdentifiers: [ + SEARCH_FIELD_METADATA_UNIVERSAL_IDENTIFIER, + ], + relatedFlatEntityMaps: { + flatSearchFieldMetadataMaps: { + byUniversalIdentifier: { + [SEARCH_FIELD_METADATA_UNIVERSAL_IDENTIFIER]: { + universalIdentifier: SEARCH_FIELD_METADATA_UNIVERSAL_IDENTIFIER, + fieldMetadataUniversalIdentifier: + DELETED_FIELD_UNIVERSAL_IDENTIFIER, + }, + }, + }, + }, + }), + ); + + expect(result.status).toBe('success'); + + if (result.status !== 'success') { + throw new Error('expected success'); + } + + expect( + Object.keys( + result.operations.searchFieldMetadata?.flatEntityToDelete ?? {}, + ), + ).toEqual([SEARCH_FIELD_METADATA_UNIVERSAL_IDENTIFIER]); + }); + + it('should not delete searchFieldMetadata rows indexing a different field', () => { + const result = handler.buildSideEffects( + buildArgs({ + searchFieldMetadataUniversalIdentifiers: [], + relatedFlatEntityMaps: { + flatSearchFieldMetadataMaps: { + byUniversalIdentifier: { + [OTHER_SEARCH_FIELD_METADATA_UNIVERSAL_IDENTIFIER]: { + universalIdentifier: + OTHER_SEARCH_FIELD_METADATA_UNIVERSAL_IDENTIFIER, + fieldMetadataUniversalIdentifier: + OTHER_FIELD_UNIVERSAL_IDENTIFIER, + }, + }, + }, + }, + }), + ); + + expect(result.status).toBe('noop'); + }); + + it('should be a noop when the workspace has no searchFieldMetadata rows', () => { + const result = handler.buildSideEffects( + buildArgs({ + searchFieldMetadataUniversalIdentifiers: [ + SEARCH_FIELD_METADATA_UNIVERSAL_IDENTIFIER, + ], + relatedFlatEntityMaps: { + flatSearchFieldMetadataMaps: { byUniversalIdentifier: {} }, + }, + }), + ); + + expect(result.status).toBe('noop'); + }); +}); diff --git a/packages/twenty-server/src/engine/metadata-modules/metadata-side-effect/handlers/field-metadata/services/field-search-field-metadata-on-delete-side-effect-handler.service.ts b/packages/twenty-server/src/engine/metadata-modules/metadata-side-effect/handlers/field-metadata/services/field-search-field-metadata-on-delete-side-effect-handler.service.ts new file mode 100644 index 0000000000..a024da9dcc --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/metadata-side-effect/handlers/field-metadata/services/field-search-field-metadata-on-delete-side-effect-handler.service.ts @@ -0,0 +1,58 @@ +import { Injectable } from '@nestjs/common'; + +import { isDefined } from 'twenty-shared/utils'; + +import { type MetadataUniversalFlatEntity } from 'src/engine/metadata-modules/flat-entity/types/metadata-universal-flat-entity.type'; +import { + type BuildSideEffectsArgs, + MetadataSideEffectHandler, +} from 'src/engine/metadata-modules/metadata-side-effect/interfaces/base-metadata-side-effect-handler.service'; +import { type MetadataSideEffectResult } from 'src/engine/metadata-modules/metadata-side-effect/types/metadata-side-effect-result.type'; + +@Injectable() +export class FieldSearchFieldMetadataOnDeleteSideEffectHandlerService extends MetadataSideEffectHandler( + { + operation: 'delete', + metadataName: 'fieldMetadata', + name: 'fieldSearchFieldMetadataOnDelete', + description: + 'When a field is deleted, cascade-delete every searchFieldMetadata row that indexes it. searchFieldMetadata is excluded from manifest deletion inference, so the cascade must be explicit here to cover both the API and manifest paths (the object-scoped cascade only fires on object deletion).', + }, +) { + buildSideEffects({ + flatEntity: flatFieldMetadata, + relatedFlatEntityMaps, + }: BuildSideEffectsArgs<'fieldMetadata'>): MetadataSideEffectResult { + const searchFieldMetadataToDelete: Record< + string, + MetadataUniversalFlatEntity<'searchFieldMetadata'> + > = {}; + + for (const searchFieldMetadataUniversalIdentifier of flatFieldMetadata.searchFieldMetadataUniversalIdentifiers) { + const flatSearchFieldMetadata = + relatedFlatEntityMaps.flatSearchFieldMetadataMaps.byUniversalIdentifier[ + searchFieldMetadataUniversalIdentifier + ]; + + if (!isDefined(flatSearchFieldMetadata)) { + continue; + } + + searchFieldMetadataToDelete[flatSearchFieldMetadata.universalIdentifier] = + flatSearchFieldMetadata; + } + + if (Object.keys(searchFieldMetadataToDelete).length === 0) { + return { status: 'noop' }; + } + + return { + status: 'success', + operations: { + searchFieldMetadata: { + flatEntityToDelete: searchFieldMetadataToDelete, + }, + }, + }; + } +} diff --git a/packages/twenty-server/src/engine/metadata-modules/metadata-side-effect/handlers/metadata-side-effect-handlers.module.ts b/packages/twenty-server/src/engine/metadata-modules/metadata-side-effect/handlers/metadata-side-effect-handlers.module.ts index 8da389827c..cd38954764 100644 --- a/packages/twenty-server/src/engine/metadata-modules/metadata-side-effect/handlers/metadata-side-effect-handlers.module.ts +++ b/packages/twenty-server/src/engine/metadata-modules/metadata-side-effect/handlers/metadata-side-effect-handlers.module.ts @@ -1,14 +1,24 @@ import { Module } from '@nestjs/common'; +import { FieldSearchFieldMetadataOnDeleteSideEffectHandlerService } from 'src/engine/metadata-modules/metadata-side-effect/handlers/field-metadata/services/field-search-field-metadata-on-delete-side-effect-handler.service'; import { FieldUniqueBackingIndexOnCreateSideEffectHandlerService } from 'src/engine/metadata-modules/metadata-side-effect/handlers/field-metadata/services/field-unique-backing-index-on-create-side-effect-handler.service'; import { FieldUniqueBackingIndexOnDeleteSideEffectHandlerService } from 'src/engine/metadata-modules/metadata-side-effect/handlers/field-metadata/services/field-unique-backing-index-on-delete-side-effect-handler.service'; import { FieldUniqueBackingIndexOnUpdateSideEffectHandlerService } from 'src/engine/metadata-modules/metadata-side-effect/handlers/field-metadata/services/field-unique-backing-index-on-update-side-effect-handler.service'; +import { ObjectSearchVectorOnCreateSideEffectHandlerService } from 'src/engine/metadata-modules/metadata-side-effect/handlers/object-metadata/services/object-search-vector-on-create-side-effect-handler.service'; +import { ObjectSearchVectorOnUpdateSideEffectHandlerService } from 'src/engine/metadata-modules/metadata-side-effect/handlers/object-metadata/services/object-search-vector-on-update-side-effect-handler.service'; +import { ObjectSystemFieldsOnCreateSideEffectHandlerService } from 'src/engine/metadata-modules/metadata-side-effect/handlers/object-metadata/services/object-system-fields-on-create-side-effect-handler.service'; +import { ObjectSystemSideEffectsOnDeleteSideEffectHandlerService } from 'src/engine/metadata-modules/metadata-side-effect/handlers/object-metadata/services/object-system-side-effects-on-delete-side-effect-handler.service'; @Module({ providers: [ FieldUniqueBackingIndexOnCreateSideEffectHandlerService, FieldUniqueBackingIndexOnUpdateSideEffectHandlerService, FieldUniqueBackingIndexOnDeleteSideEffectHandlerService, + FieldSearchFieldMetadataOnDeleteSideEffectHandlerService, + ObjectSystemFieldsOnCreateSideEffectHandlerService, + ObjectSearchVectorOnCreateSideEffectHandlerService, + ObjectSearchVectorOnUpdateSideEffectHandlerService, + ObjectSystemSideEffectsOnDeleteSideEffectHandlerService, ], }) export class MetadataSideEffectHandlersModule {} diff --git a/packages/twenty-server/src/engine/metadata-modules/metadata-side-effect/handlers/object-metadata/services/__tests__/object-search-vector-on-create-side-effect-handler.service.spec.ts b/packages/twenty-server/src/engine/metadata-modules/metadata-side-effect/handlers/object-metadata/services/__tests__/object-search-vector-on-create-side-effect-handler.service.spec.ts new file mode 100644 index 0000000000..f11cfc5394 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/metadata-side-effect/handlers/object-metadata/services/__tests__/object-search-vector-on-create-side-effect-handler.service.spec.ts @@ -0,0 +1,275 @@ +import { getFieldUniversalIdentifier } from 'twenty-shared/application'; +import { FieldMetadataType } from 'twenty-shared/types'; + +import { type AllFlatEntityOperationRecordByMetadataName } from 'src/engine/metadata-modules/flat-entity/types/all-flat-entity-operation-record-by-metadata-name.type'; +import { type BuildSideEffectsArgs } from 'src/engine/metadata-modules/metadata-side-effect/interfaces/base-metadata-side-effect-handler.service'; +import { ObjectSearchVectorOnCreateSideEffectHandlerService } from 'src/engine/metadata-modules/metadata-side-effect/handlers/object-metadata/services/object-search-vector-on-create-side-effect-handler.service'; + +const APPLICATION_UNIVERSAL_IDENTIFIER = 'a1a2a3a4-a5a6-4000-8000-000000000001'; +const OBJECT_UNIVERSAL_IDENTIFIER = 'b1b2b3b4-b5b6-4000-8000-000000000001'; + +const NAME_FIELD_UNIVERSAL_IDENTIFIER = getFieldUniversalIdentifier({ + applicationUniversalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER, + objectUniversalIdentifier: OBJECT_UNIVERSAL_IDENTIFIER, + name: 'name', +}); + +const SEARCH_VECTOR_FIELD_UNIVERSAL_IDENTIFIER = getFieldUniversalIdentifier({ + applicationUniversalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER, + objectUniversalIdentifier: OBJECT_UNIVERSAL_IDENTIFIER, + name: 'searchVector', +}); + +type PendingFieldMetadata = { + universalIdentifier: string; + type: FieldMetadataType; +}; + +const buildArgs = ({ + isSearchable, + labelIdentifierFieldMetadataUniversalIdentifier, + pendingFieldMetadatas = [], + relatedFlatEntityMaps = { + flatFieldMetadataMaps: { byUniversalIdentifier: {} }, + flatSearchFieldMetadataMaps: { byUniversalIdentifier: {} }, + flatIndexMaps: { byUniversalIdentifier: {} }, + }, +}: { + isSearchable: boolean; + labelIdentifierFieldMetadataUniversalIdentifier: string | null; + pendingFieldMetadatas?: PendingFieldMetadata[]; + relatedFlatEntityMaps?: object; +}): BuildSideEffectsArgs<'objectMetadata'> => + ({ + flatEntity: { + applicationUniversalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER, + universalIdentifier: OBJECT_UNIVERSAL_IDENTIFIER, + nameSingular: 'ticket', + isSearchable, + labelIdentifierFieldMetadataUniversalIdentifier, + }, + allFlatEntityOperationRecordByMetadataName: { + ...(pendingFieldMetadatas.length > 0 && { + fieldMetadata: { + flatEntityToCreate: Object.fromEntries( + pendingFieldMetadatas.map((pendingFieldMetadata) => [ + pendingFieldMetadata.universalIdentifier, + pendingFieldMetadata, + ]), + ), + flatEntityToUpdate: {}, + flatEntityToDelete: {}, + }, + }), + } as unknown as AllFlatEntityOperationRecordByMetadataName, + relatedFlatEntityMaps, + context: {}, + }) as unknown as BuildSideEffectsArgs<'objectMetadata'>; + +const PENDING_TEXT_NAME_FIELD: PendingFieldMetadata = { + universalIdentifier: NAME_FIELD_UNIVERSAL_IDENTIFIER, + type: FieldMetadataType.TEXT, +}; + +describe('ObjectSearchVectorOnCreateSideEffectHandlerService', () => { + const handler = + new (ObjectSearchVectorOnCreateSideEffectHandlerService as unknown as new () => ObjectSearchVectorOnCreateSideEffectHandlerService)(); + + it('should always provision the searchVector field and its GIN index, plus a searchFieldMetadata for a searchable object with a searchable label identifier', () => { + const result = handler.buildSideEffects( + buildArgs({ + isSearchable: true, + labelIdentifierFieldMetadataUniversalIdentifier: + NAME_FIELD_UNIVERSAL_IDENTIFIER, + pendingFieldMetadatas: [PENDING_TEXT_NAME_FIELD], + }), + ); + + expect(result.status).toBe('success'); + + if (result.status !== 'success') { + throw new Error('expected success'); + } + + const createdFieldUniversalIdentifiers = Object.keys( + result.operations.fieldMetadata?.flatEntityToCreate ?? {}, + ); + + expect(createdFieldUniversalIdentifiers).toEqual([ + SEARCH_VECTOR_FIELD_UNIVERSAL_IDENTIFIER, + ]); + + expect( + Object.keys(result.operations.index?.flatEntityToCreate ?? {}), + ).toHaveLength(1); + + expect( + Object.keys( + result.operations.searchFieldMetadata?.flatEntityToCreate ?? {}, + ), + ).toHaveLength(1); + }); + + it('should still provision the searchVector field and GIN index but no searchFieldMetadata when the object is not searchable', () => { + const result = handler.buildSideEffects( + buildArgs({ + isSearchable: false, + labelIdentifierFieldMetadataUniversalIdentifier: + NAME_FIELD_UNIVERSAL_IDENTIFIER, + pendingFieldMetadatas: [PENDING_TEXT_NAME_FIELD], + }), + ); + + expect(result.status).toBe('success'); + + if (result.status !== 'success') { + throw new Error('expected success'); + } + + expect( + Object.keys(result.operations.fieldMetadata?.flatEntityToCreate ?? {}), + ).toHaveLength(1); + expect( + Object.keys(result.operations.index?.flatEntityToCreate ?? {}), + ).toHaveLength(1); + expect(result.operations.searchFieldMetadata).toBeUndefined(); + }); + + it('should not provision a searchFieldMetadata for junction objects whose label identifier is the id field', () => { + const idFieldUniversalIdentifier = getFieldUniversalIdentifier({ + applicationUniversalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER, + objectUniversalIdentifier: OBJECT_UNIVERSAL_IDENTIFIER, + name: 'id', + }); + + const result = handler.buildSideEffects( + buildArgs({ + isSearchable: true, + labelIdentifierFieldMetadataUniversalIdentifier: + idFieldUniversalIdentifier, + pendingFieldMetadatas: [ + { + universalIdentifier: idFieldUniversalIdentifier, + type: FieldMetadataType.UUID, + }, + ], + }), + ); + + expect(result.status).toBe('success'); + + if (result.status !== 'success') { + throw new Error('expected success'); + } + + expect(result.operations.searchFieldMetadata).toBeUndefined(); + }); + + it('should not provision a searchFieldMetadata when the caller published a non-searchable field at the derived name identifier', () => { + // The universal identifier derivation is name-based, so a caller-provided + // `name` field of any type lands on the derived identifier. The handler must + // resolve the actual type instead of assuming TEXT. + const result = handler.buildSideEffects( + buildArgs({ + isSearchable: true, + labelIdentifierFieldMetadataUniversalIdentifier: + NAME_FIELD_UNIVERSAL_IDENTIFIER, + pendingFieldMetadatas: [ + { + universalIdentifier: NAME_FIELD_UNIVERSAL_IDENTIFIER, + type: FieldMetadataType.NUMBER, + }, + ], + }), + ); + + expect(result.status).toBe('success'); + + if (result.status !== 'success') { + throw new Error('expected success'); + } + + expect(result.operations.searchFieldMetadata).toBeUndefined(); + }); + + it('should not provision a searchFieldMetadata when the label identifier field cannot be resolved', () => { + const result = handler.buildSideEffects( + buildArgs({ + isSearchable: true, + labelIdentifierFieldMetadataUniversalIdentifier: + NAME_FIELD_UNIVERSAL_IDENTIFIER, + }), + ); + + expect(result.status).toBe('success'); + + if (result.status !== 'success') { + throw new Error('expected success'); + } + + expect(result.operations.searchFieldMetadata).toBeUndefined(); + }); + + it('should resolve the label identifier field type from the existing maps when it is not part of the pending operations', () => { + const result = handler.buildSideEffects( + buildArgs({ + isSearchable: true, + labelIdentifierFieldMetadataUniversalIdentifier: + NAME_FIELD_UNIVERSAL_IDENTIFIER, + relatedFlatEntityMaps: { + flatFieldMetadataMaps: { + byUniversalIdentifier: { + [NAME_FIELD_UNIVERSAL_IDENTIFIER]: { + universalIdentifier: NAME_FIELD_UNIVERSAL_IDENTIFIER, + type: FieldMetadataType.TEXT, + }, + }, + }, + flatSearchFieldMetadataMaps: { byUniversalIdentifier: {} }, + flatIndexMaps: { byUniversalIdentifier: {} }, + }, + }), + ); + + expect(result.status).toBe('success'); + + if (result.status !== 'success') { + throw new Error('expected success'); + } + + expect( + Object.keys( + result.operations.searchFieldMetadata?.flatEntityToCreate ?? {}, + ), + ).toHaveLength(1); + }); + + it('should be deterministic across invocations', () => { + const firstResult = handler.buildSideEffects( + buildArgs({ + isSearchable: true, + labelIdentifierFieldMetadataUniversalIdentifier: + NAME_FIELD_UNIVERSAL_IDENTIFIER, + pendingFieldMetadatas: [PENDING_TEXT_NAME_FIELD], + }), + ); + const secondResult = handler.buildSideEffects( + buildArgs({ + isSearchable: true, + labelIdentifierFieldMetadataUniversalIdentifier: + NAME_FIELD_UNIVERSAL_IDENTIFIER, + pendingFieldMetadatas: [PENDING_TEXT_NAME_FIELD], + }), + ); + + if (firstResult.status !== 'success' || secondResult.status !== 'success') { + throw new Error('expected success'); + } + + expect( + Object.keys(firstResult.operations.index?.flatEntityToCreate ?? {}), + ).toEqual( + Object.keys(secondResult.operations.index?.flatEntityToCreate ?? {}), + ); + }); +}); diff --git a/packages/twenty-server/src/engine/metadata-modules/metadata-side-effect/handlers/object-metadata/services/__tests__/object-search-vector-on-update-side-effect-handler.service.spec.ts b/packages/twenty-server/src/engine/metadata-modules/metadata-side-effect/handlers/object-metadata/services/__tests__/object-search-vector-on-update-side-effect-handler.service.spec.ts new file mode 100644 index 0000000000..363d031642 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/metadata-side-effect/handlers/object-metadata/services/__tests__/object-search-vector-on-update-side-effect-handler.service.spec.ts @@ -0,0 +1,300 @@ +import { getFieldUniversalIdentifier } from 'twenty-shared/application'; +import { FieldMetadataType } from 'twenty-shared/types'; + +import { type AllFlatEntityOperationRecordByMetadataName } from 'src/engine/metadata-modules/flat-entity/types/all-flat-entity-operation-record-by-metadata-name.type'; +import { type BuildSideEffectsArgs } from 'src/engine/metadata-modules/metadata-side-effect/interfaces/base-metadata-side-effect-handler.service'; +import { ObjectSearchVectorOnUpdateSideEffectHandlerService } from 'src/engine/metadata-modules/metadata-side-effect/handlers/object-metadata/services/object-search-vector-on-update-side-effect-handler.service'; + +const APPLICATION_UNIVERSAL_IDENTIFIER = 'a1a2a3a4-a5a6-4000-8000-000000000001'; +const OBJECT_UNIVERSAL_IDENTIFIER = 'b1b2b3b4-b5b6-4000-8000-000000000001'; + +const NAME_FIELD_UNIVERSAL_IDENTIFIER = getFieldUniversalIdentifier({ + applicationUniversalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER, + objectUniversalIdentifier: OBJECT_UNIVERSAL_IDENTIFIER, + name: 'name', +}); + +const TOTO_FIELD_UNIVERSAL_IDENTIFIER = getFieldUniversalIdentifier({ + applicationUniversalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER, + objectUniversalIdentifier: OBJECT_UNIVERSAL_IDENTIFIER, + name: 'toto', +}); + +const SEARCH_VECTOR_FIELD_UNIVERSAL_IDENTIFIER = getFieldUniversalIdentifier({ + applicationUniversalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER, + objectUniversalIdentifier: OBJECT_UNIVERSAL_IDENTIFIER, + name: 'searchVector', +}); + +const NAME_SEARCH_FIELD_METADATA_UNIVERSAL_IDENTIFIER = + 'c1c2c3c4-c5c6-4000-8000-000000000001'; + +type PendingFieldMetadata = { + universalIdentifier: string; + type: FieldMetadataType; +}; + +const SEARCH_VECTOR_FLAT_FIELD = { + universalIdentifier: SEARCH_VECTOR_FIELD_UNIVERSAL_IDENTIFIER, + type: FieldMetadataType.TS_VECTOR, + name: 'searchVector', +}; + +const NAME_SEARCH_FIELD_METADATA = { + universalIdentifier: NAME_SEARCH_FIELD_METADATA_UNIVERSAL_IDENTIFIER, + fieldMetadataUniversalIdentifier: NAME_FIELD_UNIVERSAL_IDENTIFIER, + position: 0, +}; + +const buildArgs = ({ + isSearchable = true, + toLabelIdentifier, + fromLabelIdentifier, + pendingFieldMetadatas = [], + existingFlatFieldMetadataByUniversalIdentifier = { + [SEARCH_VECTOR_FIELD_UNIVERSAL_IDENTIFIER]: SEARCH_VECTOR_FLAT_FIELD, + }, + existingSearchFieldMetadataByUniversalIdentifier = { + [NAME_SEARCH_FIELD_METADATA_UNIVERSAL_IDENTIFIER]: + NAME_SEARCH_FIELD_METADATA, + }, + existingObjectSearchFieldMetadataUniversalIdentifiers = [ + NAME_SEARCH_FIELD_METADATA_UNIVERSAL_IDENTIFIER, + ], + existingObjectFieldUniversalIdentifiers = [ + NAME_FIELD_UNIVERSAL_IDENTIFIER, + SEARCH_VECTOR_FIELD_UNIVERSAL_IDENTIFIER, + ], +}: { + isSearchable?: boolean; + toLabelIdentifier: string | null; + fromLabelIdentifier: string | null; + pendingFieldMetadatas?: PendingFieldMetadata[]; + existingFlatFieldMetadataByUniversalIdentifier?: object; + existingSearchFieldMetadataByUniversalIdentifier?: object; + existingObjectSearchFieldMetadataUniversalIdentifiers?: string[]; + existingObjectFieldUniversalIdentifiers?: string[]; +}): BuildSideEffectsArgs<'objectMetadata'> => + ({ + flatEntity: { + applicationUniversalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER, + universalIdentifier: OBJECT_UNIVERSAL_IDENTIFIER, + nameSingular: 'ticket', + isSearchable, + labelIdentifierFieldMetadataUniversalIdentifier: toLabelIdentifier, + }, + allFlatEntityOperationRecordByMetadataName: { + ...(pendingFieldMetadatas.length > 0 && { + fieldMetadata: { + flatEntityToCreate: Object.fromEntries( + pendingFieldMetadatas.map((pendingFieldMetadata) => [ + pendingFieldMetadata.universalIdentifier, + pendingFieldMetadata, + ]), + ), + flatEntityToUpdate: {}, + flatEntityToDelete: {}, + }, + }), + } as unknown as AllFlatEntityOperationRecordByMetadataName, + relatedFlatEntityMaps: { + flatObjectMetadataMaps: { + byUniversalIdentifier: { + [OBJECT_UNIVERSAL_IDENTIFIER]: { + universalIdentifier: OBJECT_UNIVERSAL_IDENTIFIER, + applicationUniversalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER, + labelIdentifierFieldMetadataUniversalIdentifier: + fromLabelIdentifier, + fieldUniversalIdentifiers: existingObjectFieldUniversalIdentifiers, + searchFieldMetadataUniversalIdentifiers: + existingObjectSearchFieldMetadataUniversalIdentifiers, + }, + }, + }, + flatFieldMetadataMaps: { + byUniversalIdentifier: existingFlatFieldMetadataByUniversalIdentifier, + }, + flatSearchFieldMetadataMaps: { + byUniversalIdentifier: existingSearchFieldMetadataByUniversalIdentifier, + }, + }, + context: {}, + }) as unknown as BuildSideEffectsArgs<'objectMetadata'>; + +const PENDING_TEXT_TOTO_FIELD: PendingFieldMetadata = { + universalIdentifier: TOTO_FIELD_UNIVERSAL_IDENTIFIER, + type: FieldMetadataType.TEXT, +}; + +describe('ObjectSearchVectorOnUpdateSideEffectHandlerService', () => { + const handler = + new (ObjectSearchVectorOnUpdateSideEffectHandlerService as unknown as new () => ObjectSearchVectorOnUpdateSideEffectHandlerService)(); + + it('should provision a searchFieldMetadata for the new searchable label identifier while preserving the existing surface', () => { + const result = handler.buildSideEffects( + buildArgs({ + fromLabelIdentifier: NAME_FIELD_UNIVERSAL_IDENTIFIER, + toLabelIdentifier: TOTO_FIELD_UNIVERSAL_IDENTIFIER, + pendingFieldMetadatas: [PENDING_TEXT_TOTO_FIELD], + }), + ); + + expect(result.status).toBe('success'); + + if (result.status !== 'success') { + throw new Error('expected success'); + } + + const createdSearchFieldMetadatas = + result.operations.searchFieldMetadata?.flatEntityToCreate ?? {}; + + expect(Object.keys(createdSearchFieldMetadatas)).toHaveLength(1); + + const [createdSearchFieldMetadata] = Object.values( + createdSearchFieldMetadatas, + ); + + expect(createdSearchFieldMetadata.fieldMetadataUniversalIdentifier).toBe( + TOTO_FIELD_UNIVERSAL_IDENTIFIER, + ); + expect( + createdSearchFieldMetadata.tsVectorFieldMetadataUniversalIdentifier, + ).toBe(SEARCH_VECTOR_FIELD_UNIVERSAL_IDENTIFIER); + // Appended after the pre-existing name row (position 0). + expect(createdSearchFieldMetadata.position).toBe(1); + }); + + it('should resolve the new label identifier field type from the existing maps', () => { + const result = handler.buildSideEffects( + buildArgs({ + fromLabelIdentifier: NAME_FIELD_UNIVERSAL_IDENTIFIER, + toLabelIdentifier: TOTO_FIELD_UNIVERSAL_IDENTIFIER, + existingFlatFieldMetadataByUniversalIdentifier: { + [SEARCH_VECTOR_FIELD_UNIVERSAL_IDENTIFIER]: SEARCH_VECTOR_FLAT_FIELD, + [TOTO_FIELD_UNIVERSAL_IDENTIFIER]: { + universalIdentifier: TOTO_FIELD_UNIVERSAL_IDENTIFIER, + type: FieldMetadataType.TEXT, + }, + }, + }), + ); + + expect(result.status).toBe('success'); + + if (result.status !== 'success') { + throw new Error('expected success'); + } + + expect( + Object.keys( + result.operations.searchFieldMetadata?.flatEntityToCreate ?? {}, + ), + ).toHaveLength(1); + }); + + it('should be a noop when the object is not searchable', () => { + const result = handler.buildSideEffects( + buildArgs({ + isSearchable: false, + fromLabelIdentifier: NAME_FIELD_UNIVERSAL_IDENTIFIER, + toLabelIdentifier: TOTO_FIELD_UNIVERSAL_IDENTIFIER, + pendingFieldMetadatas: [PENDING_TEXT_TOTO_FIELD], + }), + ); + + expect(result.status).toBe('noop'); + }); + + it('should be a noop when the label identifier did not change', () => { + const result = handler.buildSideEffects( + buildArgs({ + fromLabelIdentifier: NAME_FIELD_UNIVERSAL_IDENTIFIER, + toLabelIdentifier: NAME_FIELD_UNIVERSAL_IDENTIFIER, + pendingFieldMetadatas: [PENDING_TEXT_TOTO_FIELD], + }), + ); + + expect(result.status).toBe('noop'); + }); + + it('should be a noop when the new label identifier is the system id field (junction object)', () => { + const idFieldUniversalIdentifier = getFieldUniversalIdentifier({ + applicationUniversalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER, + objectUniversalIdentifier: OBJECT_UNIVERSAL_IDENTIFIER, + name: 'id', + }); + + const result = handler.buildSideEffects( + buildArgs({ + fromLabelIdentifier: NAME_FIELD_UNIVERSAL_IDENTIFIER, + toLabelIdentifier: idFieldUniversalIdentifier, + pendingFieldMetadatas: [ + { + universalIdentifier: idFieldUniversalIdentifier, + type: FieldMetadataType.UUID, + }, + ], + }), + ); + + expect(result.status).toBe('noop'); + }); + + it('should be a noop when the new label identifier field is not a searchable type', () => { + const result = handler.buildSideEffects( + buildArgs({ + fromLabelIdentifier: NAME_FIELD_UNIVERSAL_IDENTIFIER, + toLabelIdentifier: TOTO_FIELD_UNIVERSAL_IDENTIFIER, + pendingFieldMetadatas: [ + { + universalIdentifier: TOTO_FIELD_UNIVERSAL_IDENTIFIER, + type: FieldMetadataType.NUMBER, + }, + ], + }), + ); + + expect(result.status).toBe('noop'); + }); + + it('should be a noop when the new label identifier is already indexed', () => { + const result = handler.buildSideEffects( + buildArgs({ + fromLabelIdentifier: NAME_FIELD_UNIVERSAL_IDENTIFIER, + toLabelIdentifier: TOTO_FIELD_UNIVERSAL_IDENTIFIER, + pendingFieldMetadatas: [PENDING_TEXT_TOTO_FIELD], + existingSearchFieldMetadataByUniversalIdentifier: { + [NAME_SEARCH_FIELD_METADATA_UNIVERSAL_IDENTIFIER]: + NAME_SEARCH_FIELD_METADATA, + 'toto-search-row': { + universalIdentifier: 'toto-search-row', + fieldMetadataUniversalIdentifier: TOTO_FIELD_UNIVERSAL_IDENTIFIER, + position: 1, + }, + }, + existingObjectSearchFieldMetadataUniversalIdentifiers: [ + NAME_SEARCH_FIELD_METADATA_UNIVERSAL_IDENTIFIER, + 'toto-search-row', + ], + }), + ); + + expect(result.status).toBe('noop'); + }); + + it('should be a noop when the object has no searchVector field to attach to', () => { + const result = handler.buildSideEffects( + buildArgs({ + fromLabelIdentifier: NAME_FIELD_UNIVERSAL_IDENTIFIER, + toLabelIdentifier: TOTO_FIELD_UNIVERSAL_IDENTIFIER, + pendingFieldMetadatas: [PENDING_TEXT_TOTO_FIELD], + existingFlatFieldMetadataByUniversalIdentifier: {}, + existingObjectFieldUniversalIdentifiers: [ + NAME_FIELD_UNIVERSAL_IDENTIFIER, + ], + }), + ); + + expect(result.status).toBe('noop'); + }); +}); diff --git a/packages/twenty-server/src/engine/metadata-modules/metadata-side-effect/handlers/object-metadata/services/__tests__/object-system-fields-on-create-side-effect-handler.service.spec.ts b/packages/twenty-server/src/engine/metadata-modules/metadata-side-effect/handlers/object-metadata/services/__tests__/object-system-fields-on-create-side-effect-handler.service.spec.ts new file mode 100644 index 0000000000..344b85d115 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/metadata-side-effect/handlers/object-metadata/services/__tests__/object-system-fields-on-create-side-effect-handler.service.spec.ts @@ -0,0 +1,130 @@ +import { getFieldUniversalIdentifier } from 'twenty-shared/application'; + +import { type AllFlatEntityOperationRecordByMetadataName } from 'src/engine/metadata-modules/flat-entity/types/all-flat-entity-operation-record-by-metadata-name.type'; +import { type BuildSideEffectsArgs } from 'src/engine/metadata-modules/metadata-side-effect/interfaces/base-metadata-side-effect-handler.service'; +import { ObjectSystemFieldsOnCreateSideEffectHandlerService } from 'src/engine/metadata-modules/metadata-side-effect/handlers/object-metadata/services/object-system-fields-on-create-side-effect-handler.service'; + +const APPLICATION_UNIVERSAL_IDENTIFIER = 'a1a2a3a4-a5a6-4000-8000-000000000001'; +const OBJECT_UNIVERSAL_IDENTIFIER = 'b1b2b3b4-b5b6-4000-8000-000000000001'; + +const SYSTEM_FIELD_NAMES = [ + 'id', + 'createdAt', + 'updatedAt', + 'deletedAt', + 'createdBy', + 'updatedBy', + 'position', +] as const; + +const NAME_FIELD_UNIVERSAL_IDENTIFIER = getFieldUniversalIdentifier({ + applicationUniversalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER, + objectUniversalIdentifier: OBJECT_UNIVERSAL_IDENTIFIER, + name: 'name', +}); + +const SEARCH_VECTOR_FIELD_UNIVERSAL_IDENTIFIER = getFieldUniversalIdentifier({ + applicationUniversalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER, + objectUniversalIdentifier: OBJECT_UNIVERSAL_IDENTIFIER, + name: 'searchVector', +}); + +const buildArgs = ({ + labelIdentifierFieldMetadataUniversalIdentifier, + allFlatEntityOperationRecordByMetadataName = {} as unknown as AllFlatEntityOperationRecordByMetadataName, + relatedFlatEntityMaps = {}, +}: { + labelIdentifierFieldMetadataUniversalIdentifier: string; + allFlatEntityOperationRecordByMetadataName?: AllFlatEntityOperationRecordByMetadataName; + relatedFlatEntityMaps?: object; +}): BuildSideEffectsArgs<'objectMetadata'> => + ({ + flatEntity: { + applicationUniversalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER, + universalIdentifier: OBJECT_UNIVERSAL_IDENTIFIER, + labelIdentifierFieldMetadataUniversalIdentifier, + }, + allFlatEntityOperationRecordByMetadataName, + relatedFlatEntityMaps, + context: {}, + }) as unknown as BuildSideEffectsArgs<'objectMetadata'>; + +describe('ObjectSystemFieldsOnCreateSideEffectHandlerService', () => { + const handler = + new (ObjectSystemFieldsOnCreateSideEffectHandlerService as unknown as new () => ObjectSystemFieldsOnCreateSideEffectHandlerService)(); + + it('should synthesize exactly the 7 reserved system fields and never the caller-provided name field nor the searchVector field', () => { + const result = handler.buildSideEffects( + buildArgs({ + labelIdentifierFieldMetadataUniversalIdentifier: + NAME_FIELD_UNIVERSAL_IDENTIFIER, + }), + ); + + expect(result.status).toBe('success'); + + if (result.status !== 'success') { + throw new Error('expected success'); + } + + const createdUniversalIdentifiers = Object.keys( + result.operations.fieldMetadata?.flatEntityToCreate ?? {}, + ); + + expect(createdUniversalIdentifiers).toHaveLength(7); + expect(createdUniversalIdentifiers).not.toContain( + NAME_FIELD_UNIVERSAL_IDENTIFIER, + ); + expect(createdUniversalIdentifiers).not.toContain( + SEARCH_VECTOR_FIELD_UNIVERSAL_IDENTIFIER, + ); + + for (const name of SYSTEM_FIELD_NAMES) { + expect(createdUniversalIdentifiers).toContain( + getFieldUniversalIdentifier({ + applicationUniversalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER, + objectUniversalIdentifier: OBJECT_UNIVERSAL_IDENTIFIER, + name, + }), + ); + } + }); + + it('should still emit all system fields even when they already exist in the from-state (dedup delegated to the engine merge)', () => { + const alreadyPresentByUniversalIdentifier: Record = {}; + + for (const name of SYSTEM_FIELD_NAMES) { + const universalIdentifier = getFieldUniversalIdentifier({ + applicationUniversalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER, + objectUniversalIdentifier: OBJECT_UNIVERSAL_IDENTIFIER, + name, + }); + + alreadyPresentByUniversalIdentifier[universalIdentifier] = { + universalIdentifier, + }; + } + + const result = handler.buildSideEffects( + buildArgs({ + labelIdentifierFieldMetadataUniversalIdentifier: + NAME_FIELD_UNIVERSAL_IDENTIFIER, + relatedFlatEntityMaps: { + flatFieldMetadataMaps: { + byUniversalIdentifier: alreadyPresentByUniversalIdentifier, + }, + }, + }), + ); + + expect(result.status).toBe('success'); + + if (result.status !== 'success') { + throw new Error('expected success'); + } + + expect( + Object.keys(result.operations.fieldMetadata?.flatEntityToCreate ?? {}), + ).toHaveLength(7); + }); +}); diff --git a/packages/twenty-server/src/engine/metadata-modules/metadata-side-effect/handlers/object-metadata/services/__tests__/object-system-side-effects-on-delete-side-effect-handler.service.spec.ts b/packages/twenty-server/src/engine/metadata-modules/metadata-side-effect/handlers/object-metadata/services/__tests__/object-system-side-effects-on-delete-side-effect-handler.service.spec.ts new file mode 100644 index 0000000000..655572f049 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/metadata-side-effect/handlers/object-metadata/services/__tests__/object-system-side-effects-on-delete-side-effect-handler.service.spec.ts @@ -0,0 +1,125 @@ +import { type BuildSideEffectsArgs } from 'src/engine/metadata-modules/metadata-side-effect/interfaces/base-metadata-side-effect-handler.service'; +import { ObjectSystemSideEffectsOnDeleteSideEffectHandlerService } from 'src/engine/metadata-modules/metadata-side-effect/handlers/object-metadata/services/object-system-side-effects-on-delete-side-effect-handler.service'; + +const OBJECT_UNIVERSAL_IDENTIFIER = 'b1b2b3b4-b5b6-4000-8000-000000000001'; +const OTHER_OBJECT_UNIVERSAL_IDENTIFIER = + 'c1c2c3c4-c5c6-4000-8000-000000000001'; + +const SYSTEM_FIELD_UNIVERSAL_IDENTIFIER = + 'd1d2d3d4-d5d6-4000-8000-000000000001'; +const AUTHOR_FIELD_UNIVERSAL_IDENTIFIER = + 'd1d2d3d4-d5d6-4000-8000-000000000002'; +const GIN_INDEX_UNIVERSAL_IDENTIFIER = 'e1e2e3e4-e5e6-4000-8000-000000000001'; +const SEARCH_FIELD_METADATA_UNIVERSAL_IDENTIFIER = + 'f1f2f3f4-f5f6-4000-8000-000000000001'; + +const buildArgs = ({ + relatedFlatEntityMaps, +}: { + relatedFlatEntityMaps: object; +}): BuildSideEffectsArgs<'objectMetadata'> => + ({ + flatEntity: { + universalIdentifier: OBJECT_UNIVERSAL_IDENTIFIER, + }, + allFlatEntityOperationRecordByMetadataName: {}, + relatedFlatEntityMaps, + context: {}, + }) as unknown as BuildSideEffectsArgs<'objectMetadata'>; + +describe('ObjectSystemSideEffectsOnDeleteSideEffectHandlerService', () => { + const handler = + new (ObjectSystemSideEffectsOnDeleteSideEffectHandlerService as unknown as new () => ObjectSystemSideEffectsOnDeleteSideEffectHandlerService)(); + + it('should cascade-delete engine-owned fields, indexes and searchFieldMetadata while leaving author fields untouched', () => { + const result = handler.buildSideEffects( + buildArgs({ + relatedFlatEntityMaps: { + flatFieldMetadataMaps: { + byUniversalIdentifier: { + [SYSTEM_FIELD_UNIVERSAL_IDENTIFIER]: { + universalIdentifier: SYSTEM_FIELD_UNIVERSAL_IDENTIFIER, + isSystemSideEffect: true, + objectMetadataUniversalIdentifier: OBJECT_UNIVERSAL_IDENTIFIER, + relationTargetObjectMetadataUniversalIdentifier: null, + }, + [AUTHOR_FIELD_UNIVERSAL_IDENTIFIER]: { + universalIdentifier: AUTHOR_FIELD_UNIVERSAL_IDENTIFIER, + isSystemSideEffect: false, + objectMetadataUniversalIdentifier: OBJECT_UNIVERSAL_IDENTIFIER, + relationTargetObjectMetadataUniversalIdentifier: null, + }, + }, + }, + flatIndexMaps: { + byUniversalIdentifier: { + [GIN_INDEX_UNIVERSAL_IDENTIFIER]: { + universalIdentifier: GIN_INDEX_UNIVERSAL_IDENTIFIER, + isSystemSideEffect: true, + objectMetadataUniversalIdentifier: OBJECT_UNIVERSAL_IDENTIFIER, + universalFlatIndexFieldMetadatas: [], + }, + }, + }, + flatSearchFieldMetadataMaps: { + byUniversalIdentifier: { + [SEARCH_FIELD_METADATA_UNIVERSAL_IDENTIFIER]: { + universalIdentifier: SEARCH_FIELD_METADATA_UNIVERSAL_IDENTIFIER, + objectMetadataUniversalIdentifier: OBJECT_UNIVERSAL_IDENTIFIER, + }, + }, + }, + }, + }), + ); + + expect(result.status).toBe('success'); + + if (result.status !== 'success') { + throw new Error('expected success'); + } + + const deletedFieldUniversalIdentifiers = Object.keys( + result.operations.fieldMetadata?.flatEntityToDelete ?? {}, + ); + + expect(deletedFieldUniversalIdentifiers).toEqual([ + SYSTEM_FIELD_UNIVERSAL_IDENTIFIER, + ]); + expect(deletedFieldUniversalIdentifiers).not.toContain( + AUTHOR_FIELD_UNIVERSAL_IDENTIFIER, + ); + expect( + Object.keys(result.operations.index?.flatEntityToDelete ?? {}), + ).toEqual([GIN_INDEX_UNIVERSAL_IDENTIFIER]); + expect( + Object.keys( + result.operations.searchFieldMetadata?.flatEntityToDelete ?? {}, + ), + ).toEqual([SEARCH_FIELD_METADATA_UNIVERSAL_IDENTIFIER]); + }); + + it('should not delete entities belonging to a different object', () => { + const result = handler.buildSideEffects( + buildArgs({ + relatedFlatEntityMaps: { + flatFieldMetadataMaps: { + byUniversalIdentifier: { + [SYSTEM_FIELD_UNIVERSAL_IDENTIFIER]: { + universalIdentifier: SYSTEM_FIELD_UNIVERSAL_IDENTIFIER, + isSystemSideEffect: true, + objectMetadataUniversalIdentifier: + OTHER_OBJECT_UNIVERSAL_IDENTIFIER, + relationTargetObjectMetadataUniversalIdentifier: null, + }, + }, + }, + flatIndexMaps: { byUniversalIdentifier: {} }, + flatSearchFieldMetadataMaps: { byUniversalIdentifier: {} }, + }, + }), + ); + + expect(result.status).toBe('noop'); + }); +}); diff --git a/packages/twenty-server/src/engine/metadata-modules/metadata-side-effect/handlers/object-metadata/services/object-search-vector-on-create-side-effect-handler.service.ts b/packages/twenty-server/src/engine/metadata-modules/metadata-side-effect/handlers/object-metadata/services/object-search-vector-on-create-side-effect-handler.service.ts new file mode 100644 index 0000000000..269249706c --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/metadata-side-effect/handlers/object-metadata/services/object-search-vector-on-create-side-effect-handler.service.ts @@ -0,0 +1,169 @@ +import { Injectable } from '@nestjs/common'; + +import { getFieldUniversalIdentifier } from 'twenty-shared/application'; +import { type FieldMetadataType } from 'twenty-shared/types'; +import { isDefined, isSearchableFieldType } from 'twenty-shared/utils'; + +import { buildFlatSearchFieldMetadataForField } from 'src/engine/metadata-modules/flat-search-field-metadata/utils/build-flat-search-field-metadata-for-field.util'; +import { + type BuildSideEffectsArgs, + MetadataSideEffectHandler, +} from 'src/engine/metadata-modules/metadata-side-effect/interfaces/base-metadata-side-effect-handler.service'; +import { type MetadataSideEffectOperationsByMetadataName } from 'src/engine/metadata-modules/metadata-side-effect/types/metadata-side-effect-operations-by-metadata-name.type'; +import { type MetadataSideEffectResult } from 'src/engine/metadata-modules/metadata-side-effect/types/metadata-side-effect-result.type'; +import { buildSearchVectorFlatFieldMetadataForCustomObject } from 'src/engine/metadata-modules/object-metadata/utils/build-search-vector-flat-field-metadata-for-custom-object.util'; +import { buildSearchVectorGinIndexForCustomObject } from 'src/engine/metadata-modules/object-metadata/utils/build-search-vector-gin-index-for-custom-object.util'; +import { type UniversalFlatSearchFieldMetadata } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-search-field-metadata.type'; + +@Injectable() +export class ObjectSearchVectorOnCreateSideEffectHandlerService extends MetadataSideEffectHandler( + { + operation: 'create', + metadataName: 'objectMetadata', + name: 'objectSearchVectorOnCreate', + description: + 'When an object is created, provision its full-text search surface as a single self-contained side effect: the searchVector system field, the GIN index backing it, and (for searchable objects whose label identifier is a searchable field) the searchFieldMetadata row that keeps the searchVector populated instead of NULL.', + }, +) { + buildSideEffects({ + flatEntity: flatObjectMetadata, + allFlatEntityOperationRecordByMetadataName, + relatedFlatEntityMaps, + }: BuildSideEffectsArgs<'objectMetadata'>): MetadataSideEffectResult { + const { applicationUniversalIdentifier, universalIdentifier } = + flatObjectMetadata; + + const searchVectorFlatFieldMetadata = + buildSearchVectorFlatFieldMetadataForCustomObject({ + flatObjectMetadata: { + applicationUniversalIdentifier, + universalIdentifier, + }, + }); + + const tsVectorFlatIndex = buildSearchVectorGinIndexForCustomObject({ + flatObjectMetadata, + searchVectorFlatFieldMetadata, + }); + + const operations: MetadataSideEffectOperationsByMetadataName = { + fieldMetadata: { + flatEntityToCreate: { + [searchVectorFlatFieldMetadata.universalIdentifier]: + searchVectorFlatFieldMetadata, + }, + }, + index: { + flatEntityToCreate: { + [tsVectorFlatIndex.universalIdentifier]: tsVectorFlatIndex, + }, + }, + }; + + const searchFieldMetadata = this.buildSearchFieldMetadata({ + flatObjectMetadata, + searchVectorFlatFieldMetadata, + allFlatEntityOperationRecordByMetadataName, + relatedFlatEntityMaps, + }); + + if (isDefined(searchFieldMetadata)) { + operations.searchFieldMetadata = { + flatEntityToCreate: { + [searchFieldMetadata.universalIdentifier]: searchFieldMetadata, + }, + }; + } + + return { + status: 'success', + operations, + }; + } + + private buildSearchFieldMetadata({ + flatObjectMetadata, + searchVectorFlatFieldMetadata, + allFlatEntityOperationRecordByMetadataName, + relatedFlatEntityMaps, + }: { + flatObjectMetadata: BuildSideEffectsArgs<'objectMetadata'>['flatEntity']; + searchVectorFlatFieldMetadata: { universalIdentifier: string }; + allFlatEntityOperationRecordByMetadataName: BuildSideEffectsArgs<'objectMetadata'>['allFlatEntityOperationRecordByMetadataName']; + relatedFlatEntityMaps: BuildSideEffectsArgs<'objectMetadata'>['relatedFlatEntityMaps']; + }): UniversalFlatSearchFieldMetadata | undefined { + if (flatObjectMetadata.isSearchable !== true) { + return undefined; + } + + const labelIdentifierFieldMetadataUniversalIdentifier = + flatObjectMetadata.labelIdentifierFieldMetadataUniversalIdentifier; + + if (!isDefined(labelIdentifierFieldMetadataUniversalIdentifier)) { + return undefined; + } + + const derivedIdFieldUniversalIdentifier = getFieldUniversalIdentifier({ + applicationUniversalIdentifier: + flatObjectMetadata.applicationUniversalIdentifier, + objectUniversalIdentifier: flatObjectMetadata.universalIdentifier, + name: 'id', + }); + + if ( + labelIdentifierFieldMetadataUniversalIdentifier === + derivedIdFieldUniversalIdentifier + ) { + return undefined; + } + + const labelIdentifierFieldType = this.resolveLabelIdentifierFieldType({ + labelIdentifierFieldMetadataUniversalIdentifier, + allFlatEntityOperationRecordByMetadataName, + relatedFlatEntityMaps, + }); + + if ( + !isDefined(labelIdentifierFieldType) || + !isSearchableFieldType(labelIdentifierFieldType) + ) { + return undefined; + } + + return buildFlatSearchFieldMetadataForField({ + flatObjectMetadata, + flatFieldMetadata: { + universalIdentifier: labelIdentifierFieldMetadataUniversalIdentifier, + }, + tsVectorFlatFieldMetadata: { + universalIdentifier: searchVectorFlatFieldMetadata.universalIdentifier, + }, + position: 0, + }); + } + + private resolveLabelIdentifierFieldType({ + labelIdentifierFieldMetadataUniversalIdentifier, + allFlatEntityOperationRecordByMetadataName, + relatedFlatEntityMaps, + }: { + labelIdentifierFieldMetadataUniversalIdentifier: string; + allFlatEntityOperationRecordByMetadataName: BuildSideEffectsArgs<'objectMetadata'>['allFlatEntityOperationRecordByMetadataName']; + relatedFlatEntityMaps: BuildSideEffectsArgs<'objectMetadata'>['relatedFlatEntityMaps']; + }): FieldMetadataType | undefined { + const pendingField = + allFlatEntityOperationRecordByMetadataName.fieldMetadata + ?.flatEntityToCreate[labelIdentifierFieldMetadataUniversalIdentifier]; + + if (isDefined(pendingField)) { + return pendingField.type; + } + + const existingField = + relatedFlatEntityMaps.flatFieldMetadataMaps.byUniversalIdentifier[ + labelIdentifierFieldMetadataUniversalIdentifier + ]; + + return existingField?.type; + } +} diff --git a/packages/twenty-server/src/engine/metadata-modules/metadata-side-effect/handlers/object-metadata/services/object-search-vector-on-update-side-effect-handler.service.ts b/packages/twenty-server/src/engine/metadata-modules/metadata-side-effect/handlers/object-metadata/services/object-search-vector-on-update-side-effect-handler.service.ts new file mode 100644 index 0000000000..4acf59379f --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/metadata-side-effect/handlers/object-metadata/services/object-search-vector-on-update-side-effect-handler.service.ts @@ -0,0 +1,173 @@ +import { Injectable } from '@nestjs/common'; + +import { getFieldUniversalIdentifier } from 'twenty-shared/application'; +import { type FieldMetadataType } from 'twenty-shared/types'; +import { isDefined, isSearchableFieldType } from 'twenty-shared/utils'; + +import { buildFlatSearchFieldMetadataForField } from 'src/engine/metadata-modules/flat-search-field-metadata/utils/build-flat-search-field-metadata-for-field.util'; +import { findTsVectorFlatFieldMetadataForObject } from 'src/engine/metadata-modules/flat-search-field-metadata/utils/find-ts-vector-flat-field-metadata-for-object.util'; +import { + type BuildSideEffectsArgs, + MetadataSideEffectHandler, +} from 'src/engine/metadata-modules/metadata-side-effect/interfaces/base-metadata-side-effect-handler.service'; +import { type MetadataSideEffectResult } from 'src/engine/metadata-modules/metadata-side-effect/types/metadata-side-effect-result.type'; + +@Injectable() +export class ObjectSearchVectorOnUpdateSideEffectHandlerService extends MetadataSideEffectHandler( + { + operation: 'update', + metadataName: 'objectMetadata', + name: 'objectSearchVectorOnUpdate', + description: + 'When a searchable object is relabeled onto a new searchable field, provision the searchFieldMetadata row that indexes it. Relabeling is additive: existing search rows (e.g. the provisioned name row) are preserved so the previous label identifier stays searchable. Mirrors the API update path so a manifest re-sync that changes the label identifier reaches search parity.', + }, +) { + buildSideEffects({ + flatEntity: updatedFlatObjectMetadata, + allFlatEntityOperationRecordByMetadataName, + relatedFlatEntityMaps, + }: BuildSideEffectsArgs<'objectMetadata'>): MetadataSideEffectResult { + if (updatedFlatObjectMetadata.isSearchable !== true) { + return { status: 'noop' }; + } + + const newLabelIdentifierFieldMetadataUniversalIdentifier = + updatedFlatObjectMetadata.labelIdentifierFieldMetadataUniversalIdentifier; + + if (!isDefined(newLabelIdentifierFieldMetadataUniversalIdentifier)) { + return { status: 'noop' }; + } + + // The trigger entity is the incoming (manifest/API) object with empty foreign + // key aggregators; the existing search rows, positions and searchVector field + // are resolved from the current cached object. + const existingFlatObjectMetadata = + relatedFlatEntityMaps.flatObjectMetadataMaps.byUniversalIdentifier[ + updatedFlatObjectMetadata.universalIdentifier + ]; + + if (!isDefined(existingFlatObjectMetadata)) { + return { status: 'noop' }; + } + + if ( + existingFlatObjectMetadata.labelIdentifierFieldMetadataUniversalIdentifier === + newLabelIdentifierFieldMetadataUniversalIdentifier + ) { + return { status: 'noop' }; + } + + // Junction objects use the system id field as label identifier; UUID is a + // searchable type, so a type-based check would wrongly index them. + const derivedIdFieldUniversalIdentifier = getFieldUniversalIdentifier({ + applicationUniversalIdentifier: + updatedFlatObjectMetadata.applicationUniversalIdentifier, + objectUniversalIdentifier: updatedFlatObjectMetadata.universalIdentifier, + name: 'id', + }); + + if ( + newLabelIdentifierFieldMetadataUniversalIdentifier === + derivedIdFieldUniversalIdentifier + ) { + return { status: 'noop' }; + } + + const newLabelIdentifierFieldType = this.resolveFieldType({ + fieldMetadataUniversalIdentifier: + newLabelIdentifierFieldMetadataUniversalIdentifier, + allFlatEntityOperationRecordByMetadataName, + relatedFlatEntityMaps, + }); + + if ( + !isDefined(newLabelIdentifierFieldType) || + !isSearchableFieldType(newLabelIdentifierFieldType) + ) { + return { status: 'noop' }; + } + + const existingSearchFieldMetadatas = + existingFlatObjectMetadata.searchFieldMetadataUniversalIdentifiers + .map( + (searchFieldMetadataUniversalIdentifier) => + relatedFlatEntityMaps.flatSearchFieldMetadataMaps + .byUniversalIdentifier[searchFieldMetadataUniversalIdentifier], + ) + .filter(isDefined); + + const newLabelIdentifierAlreadyIndexed = existingSearchFieldMetadatas.some( + (searchFieldMetadata) => + searchFieldMetadata.fieldMetadataUniversalIdentifier === + newLabelIdentifierFieldMetadataUniversalIdentifier, + ); + + if (newLabelIdentifierAlreadyIndexed) { + return { status: 'noop' }; + } + + const tsVectorFlatFieldMetadata = findTsVectorFlatFieldMetadataForObject({ + fieldUniversalIdentifiers: + existingFlatObjectMetadata.fieldUniversalIdentifiers, + flatFieldMetadataMaps: relatedFlatEntityMaps.flatFieldMetadataMaps, + }); + + if (!isDefined(tsVectorFlatFieldMetadata)) { + return { status: 'noop' }; + } + + const newLabelIdentifierPosition = + existingSearchFieldMetadatas.reduce( + (maxPosition, searchFieldMetadata) => + Math.max(maxPosition, searchFieldMetadata.position), + -1, + ) + 1; + + const searchFieldMetadata = buildFlatSearchFieldMetadataForField({ + flatObjectMetadata: updatedFlatObjectMetadata, + flatFieldMetadata: { + universalIdentifier: newLabelIdentifierFieldMetadataUniversalIdentifier, + }, + tsVectorFlatFieldMetadata: { + universalIdentifier: tsVectorFlatFieldMetadata.universalIdentifier, + }, + position: newLabelIdentifierPosition, + }); + + return { + status: 'success', + operations: { + searchFieldMetadata: { + flatEntityToCreate: { + [searchFieldMetadata.universalIdentifier]: searchFieldMetadata, + }, + }, + }, + }; + } + + private resolveFieldType({ + fieldMetadataUniversalIdentifier, + allFlatEntityOperationRecordByMetadataName, + relatedFlatEntityMaps, + }: { + fieldMetadataUniversalIdentifier: string; + allFlatEntityOperationRecordByMetadataName: BuildSideEffectsArgs<'objectMetadata'>['allFlatEntityOperationRecordByMetadataName']; + relatedFlatEntityMaps: BuildSideEffectsArgs<'objectMetadata'>['relatedFlatEntityMaps']; + }): FieldMetadataType | undefined { + const pendingField = + allFlatEntityOperationRecordByMetadataName.fieldMetadata + ?.flatEntityToCreate[fieldMetadataUniversalIdentifier]; + + if (isDefined(pendingField)) { + return pendingField.type; + } + + const existingField = + relatedFlatEntityMaps.flatFieldMetadataMaps.byUniversalIdentifier[ + fieldMetadataUniversalIdentifier + ]; + + return existingField?.type; + } +} diff --git a/packages/twenty-server/src/engine/metadata-modules/metadata-side-effect/handlers/object-metadata/services/object-system-fields-on-create-side-effect-handler.service.ts b/packages/twenty-server/src/engine/metadata-modules/metadata-side-effect/handlers/object-metadata/services/object-system-fields-on-create-side-effect-handler.service.ts new file mode 100644 index 0000000000..09f16efbe8 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/metadata-side-effect/handlers/object-metadata/services/object-system-fields-on-create-side-effect-handler.service.ts @@ -0,0 +1,56 @@ +import { Injectable } from '@nestjs/common'; + +import { type MetadataUniversalFlatEntity } from 'src/engine/metadata-modules/flat-entity/types/metadata-universal-flat-entity.type'; +import { + type BuildSideEffectsArgs, + MetadataSideEffectHandler, +} from 'src/engine/metadata-modules/metadata-side-effect/interfaces/base-metadata-side-effect-handler.service'; +import { type MetadataSideEffectResult } from 'src/engine/metadata-modules/metadata-side-effect/types/metadata-side-effect-result.type'; +import { buildReservedSystemFlatFieldMetadatasForCustomObject } from 'src/engine/metadata-modules/object-metadata/utils/build-reserved-system-flat-field-metadatas-for-custom-object.util'; + +@Injectable() +export class ObjectSystemFieldsOnCreateSideEffectHandlerService extends MetadataSideEffectHandler( + { + operation: 'create', + metadataName: 'objectMetadata', + name: 'objectSystemFieldsOnCreate', + description: + 'When an object is created, generate its 7 reserved system fields (id, createdAt, updatedAt, deletedAt, createdBy, updatedBy, position). The searchVector field is provisioned by the self-contained objectSearchVectorOnCreate handler alongside its GIN index and searchFieldMetadata. The default name field is NOT synthesized here: it is a caller-provided default field (SDK auto-complete on the manifest path, input transpiler on the API path).', + }, +) { + buildSideEffects({ + flatEntity: flatObjectMetadata, + }: BuildSideEffectsArgs<'objectMetadata'>): MetadataSideEffectResult { + const { applicationUniversalIdentifier, universalIdentifier } = + flatObjectMetadata; + + const reservedSystemFlatFieldMetadatas = + buildReservedSystemFlatFieldMetadatasForCustomObject({ + flatObjectMetadata: { + applicationUniversalIdentifier, + universalIdentifier, + }, + }); + + const flatEntityToCreate: Record< + string, + MetadataUniversalFlatEntity<'fieldMetadata'> + > = {}; + + for (const flatFieldMetadata of Object.values( + reservedSystemFlatFieldMetadatas, + )) { + flatEntityToCreate[flatFieldMetadata.universalIdentifier] = + flatFieldMetadata; + } + + return { + status: 'success', + operations: { + fieldMetadata: { + flatEntityToCreate, + }, + }, + }; + } +} diff --git a/packages/twenty-server/src/engine/metadata-modules/metadata-side-effect/handlers/object-metadata/services/object-system-side-effects-on-delete-side-effect-handler.service.ts b/packages/twenty-server/src/engine/metadata-modules/metadata-side-effect/handlers/object-metadata/services/object-system-side-effects-on-delete-side-effect-handler.service.ts new file mode 100644 index 0000000000..d79299a246 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/metadata-side-effect/handlers/object-metadata/services/object-system-side-effects-on-delete-side-effect-handler.service.ts @@ -0,0 +1,155 @@ +import { Injectable } from '@nestjs/common'; + +import { isDefined } from 'twenty-shared/utils'; + +import { type MetadataUniversalFlatEntity } from 'src/engine/metadata-modules/flat-entity/types/metadata-universal-flat-entity.type'; +import { + type BuildSideEffectsArgs, + MetadataSideEffectHandler, +} from 'src/engine/metadata-modules/metadata-side-effect/interfaces/base-metadata-side-effect-handler.service'; +import { type MetadataSideEffectOperationsByMetadataName } from 'src/engine/metadata-modules/metadata-side-effect/types/metadata-side-effect-operations-by-metadata-name.type'; +import { type MetadataSideEffectResult } from 'src/engine/metadata-modules/metadata-side-effect/types/metadata-side-effect-result.type'; + +@Injectable() +export class ObjectSystemSideEffectsOnDeleteSideEffectHandlerService extends MetadataSideEffectHandler( + { + operation: 'delete', + metadataName: 'objectMetadata', + name: 'objectSystemSideEffectsOnDelete', + description: + 'When an object is deleted, cascade-delete its engine-owned side effects: the reserved system fields, every system index (including the GIN searchVector index), and its searchFieldMetadata rows. These entities are excluded from manifest deletion inference, so the cascade must be explicit. Caller-provided defaults (name, default relations) are NOT engine-owned and are deleted through normal deletion inference / the object delete transpiler.', + }, +) { + buildSideEffects({ + flatEntity: flatObjectMetadata, + relatedFlatEntityMaps, + }: BuildSideEffectsArgs<'objectMetadata'>): MetadataSideEffectResult { + const objectMetadataUniversalIdentifier = + flatObjectMetadata.universalIdentifier; + + const fieldMetadataToDelete: Record< + string, + MetadataUniversalFlatEntity<'fieldMetadata'> + > = {}; + const deletedFieldUniversalIdentifiers = new Set(); + + for (const flatFieldMetadata of Object.values( + relatedFlatEntityMaps.flatFieldMetadataMaps.byUniversalIdentifier, + )) { + if (!isDefined(flatFieldMetadata)) { + continue; + } + + if (flatFieldMetadata.isSystemSideEffect !== true) { + continue; + } + + const belongsToObject = + flatFieldMetadata.objectMetadataUniversalIdentifier === + objectMetadataUniversalIdentifier; + const targetsObject = + flatFieldMetadata.relationTargetObjectMetadataUniversalIdentifier === + objectMetadataUniversalIdentifier; + + if (!belongsToObject && !targetsObject) { + continue; + } + + fieldMetadataToDelete[flatFieldMetadata.universalIdentifier] = + flatFieldMetadata; + deletedFieldUniversalIdentifiers.add( + flatFieldMetadata.universalIdentifier, + ); + } + + const indexToDelete: Record< + string, + MetadataUniversalFlatEntity<'index'> + > = {}; + + for (const flatIndexMetadata of Object.values( + relatedFlatEntityMaps.flatIndexMaps.byUniversalIdentifier, + )) { + if (!isDefined(flatIndexMetadata)) { + continue; + } + + if (flatIndexMetadata.isSystemSideEffect !== true) { + continue; + } + + const belongsToObject = + flatIndexMetadata.objectMetadataUniversalIdentifier === + objectMetadataUniversalIdentifier; + const referencesDeletedField = + flatIndexMetadata.universalFlatIndexFieldMetadatas.some( + (universalFlatIndexFieldMetadata) => + deletedFieldUniversalIdentifiers.has( + universalFlatIndexFieldMetadata.fieldMetadataUniversalIdentifier, + ), + ); + + if (!belongsToObject && !referencesDeletedField) { + continue; + } + + indexToDelete[flatIndexMetadata.universalIdentifier] = flatIndexMetadata; + } + + const searchFieldMetadataToDelete: Record< + string, + MetadataUniversalFlatEntity<'searchFieldMetadata'> + > = {}; + + for (const flatSearchFieldMetadata of Object.values( + relatedFlatEntityMaps.flatSearchFieldMetadataMaps.byUniversalIdentifier, + )) { + if (!isDefined(flatSearchFieldMetadata)) { + continue; + } + + if ( + flatSearchFieldMetadata.objectMetadataUniversalIdentifier !== + objectMetadataUniversalIdentifier + ) { + continue; + } + + searchFieldMetadataToDelete[flatSearchFieldMetadata.universalIdentifier] = + flatSearchFieldMetadata; + } + + const hasFieldMetadataToDelete = + Object.keys(fieldMetadataToDelete).length > 0; + const hasIndexToDelete = Object.keys(indexToDelete).length > 0; + const hasSearchFieldMetadataToDelete = + Object.keys(searchFieldMetadataToDelete).length > 0; + + if ( + !hasFieldMetadataToDelete && + !hasIndexToDelete && + !hasSearchFieldMetadataToDelete + ) { + return { status: 'noop' }; + } + + const operations: MetadataSideEffectOperationsByMetadataName = { + ...(hasFieldMetadataToDelete && { + fieldMetadata: { flatEntityToDelete: fieldMetadataToDelete }, + }), + ...(hasIndexToDelete && { + index: { flatEntityToDelete: indexToDelete }, + }), + ...(hasSearchFieldMetadataToDelete && { + searchFieldMetadata: { + flatEntityToDelete: searchFieldMetadataToDelete, + }, + }), + }; + + return { + status: 'success', + operations, + }; + } +} diff --git a/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.exception.ts b/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.exception.ts index 243fe799a9..2dff44ddb5 100644 --- a/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.exception.ts +++ b/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.exception.ts @@ -15,8 +15,6 @@ export enum ObjectMetadataExceptionCode { INVALID_ORM_OUTPUT = 'INVALID_ORM_OUTPUT', INTERNAL_SERVER_ERROR = 'INTERNAL_SERVER_ERROR', NAME_CONFLICT = 'NAME_CONFLICT', - MISSING_SYSTEM_FIELD = 'MISSING_SYSTEM_FIELD', - INVALID_SYSTEM_FIELD = 'INVALID_SYSTEM_FIELD', } const getObjectMetadataExceptionUserFriendlyMessage = ( @@ -41,10 +39,6 @@ const getObjectMetadataExceptionUserFriendlyMessage = ( return STANDARD_ERROR_MESSAGE; case ObjectMetadataExceptionCode.NAME_CONFLICT: return msg`A name conflict occurred.`; - case ObjectMetadataExceptionCode.MISSING_SYSTEM_FIELD: - return msg`A system field is missing.`; - case ObjectMetadataExceptionCode.INVALID_SYSTEM_FIELD: - return msg`A system field has invalid properties.`; default: assertUnreachable(code); } diff --git a/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.service.ts b/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.service.ts index 47ca8267ba..e8bb6c34bb 100644 --- a/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.service.ts @@ -42,6 +42,7 @@ import { ObjectMetadataException, ObjectMetadataExceptionCode, } from 'src/engine/metadata-modules/object-metadata/object-metadata.exception'; +import { buildReservedSystemFlatFieldMetadatasForCustomObject } from 'src/engine/metadata-modules/object-metadata/utils/build-reserved-system-flat-field-metadatas-for-custom-object.util'; import { computeFlatDefaultRecordPageLayoutToCreate } from 'src/engine/metadata-modules/object-metadata/utils/compute-flat-default-record-page-layout-to-create.util'; import { computeFlatRecordPageFieldsViewToCreate } from 'src/engine/metadata-modules/object-metadata/utils/compute-flat-record-page-fields-view-to-create.util'; import { computeFlatViewFieldsToCreate } from 'src/engine/metadata-modules/object-metadata/utils/compute-flat-view-fields-to-create.util'; @@ -483,22 +484,43 @@ export class ObjectMetadataService extends TypeOrmQueryService; - skipNameField?: boolean; -}; - -export type DefaultFlatFieldForCustomObjectMaps = ReturnType< - typeof buildDefaultFlatFieldMetadatasForCustomObject ->; - -const buildObjectSystemFlatFieldMetadatas = ({ - applicationUniversalIdentifier, - objectMetadataUniversalIdentifier, - now, - searchVectorUniversalSettings, -}: { - applicationUniversalIdentifier: string; - objectMetadataUniversalIdentifier: string; - now: string; - searchVectorUniversalSettings: UniversalFlatFieldMetadata['universalSettings']; -}) => { - const { - createdAt, - createdBy, - deletedAt, - id, - position, - searchVector, - updatedAt, - updatedBy, - } = PARTIAL_SYSTEM_FLAT_FIELD_METADATAS; - - const computeFieldUniversalIdentifier = (name: string) => - getFieldUniversalIdentifier({ - applicationUniversalIdentifier, - objectUniversalIdentifier: objectMetadataUniversalIdentifier, - name, - }); - - return { - id: { - ...id, - universalIdentifier: computeFieldUniversalIdentifier(id.name), - applicationUniversalIdentifier, - objectMetadataUniversalIdentifier, - createdAt: now, - updatedAt: now, - }, - createdAt: { - ...createdAt, - universalIdentifier: computeFieldUniversalIdentifier(createdAt.name), - applicationUniversalIdentifier, - objectMetadataUniversalIdentifier, - createdAt: now, - updatedAt: now, - }, - createdBy: { - ...createdBy, - universalIdentifier: computeFieldUniversalIdentifier(createdBy.name), - applicationUniversalIdentifier, - objectMetadataUniversalIdentifier, - createdAt: now, - updatedAt: now, - }, - deletedAt: { - ...deletedAt, - universalIdentifier: computeFieldUniversalIdentifier(deletedAt.name), - applicationUniversalIdentifier, - objectMetadataUniversalIdentifier, - createdAt: now, - updatedAt: now, - }, - position: { - ...position, - universalIdentifier: computeFieldUniversalIdentifier(position.name), - applicationUniversalIdentifier, - objectMetadataUniversalIdentifier, - createdAt: now, - updatedAt: now, - }, - searchVector: { - ...searchVector, - universalIdentifier: computeFieldUniversalIdentifier(searchVector.name), - applicationUniversalIdentifier, - objectMetadataUniversalIdentifier, - createdAt: now, - updatedAt: now, - universalSettings: searchVectorUniversalSettings, - }, - updatedAt: { - ...updatedAt, - universalIdentifier: computeFieldUniversalIdentifier(updatedAt.name), - applicationUniversalIdentifier, - objectMetadataUniversalIdentifier, - createdAt: now, - updatedAt: now, - }, - updatedBy: { - ...updatedBy, - universalIdentifier: computeFieldUniversalIdentifier(updatedBy.name), - applicationUniversalIdentifier, - objectMetadataUniversalIdentifier, - createdAt: now, - updatedAt: now, - }, - } as const satisfies Record; -}; - -// This could be replaced totally by an import schema + its transpilation when it's ready -export const buildDefaultFlatFieldMetadatasForCustomObject = ({ - flatObjectMetadata: { - applicationUniversalIdentifier, - universalIdentifier: objectMetadataUniversalIdentifier, - }, - skipNameField = false, -}: BuildDefaultFlatFieldMetadataForCustomObjectArgs) => { - const now = new Date().toISOString(); - - const nameField: UniversalFlatFieldMetadata | null = - skipNameField - ? null - : { - type: FieldMetadataType.TEXT, - isLabelSyncedWithName: false, - isUnique: false, - universalIdentifier: getFieldUniversalIdentifier({ - applicationUniversalIdentifier, - objectUniversalIdentifier: objectMetadataUniversalIdentifier, - name: 'name', - }), - name: 'name', - label: 'Name', - icon: 'IconAbc', - description: 'Name', - isNullable: true, - isActive: true, - isSystem: false, - isSystemSideEffect: true, - isUIEditable: true, - defaultValue: null, - createdAt: now, - updatedAt: now, - options: null, - overrides: null, - morphId: null, - applicationUniversalIdentifier, - objectMetadataUniversalIdentifier, - relationTargetObjectMetadataUniversalIdentifier: null, - relationTargetFieldMetadataUniversalIdentifier: null, - viewFilterUniversalIdentifiers: [], - viewFieldUniversalIdentifiers: [], - kanbanAggregateOperationViewUniversalIdentifiers: [], - calendarViewUniversalIdentifiers: [], - mainGroupByFieldMetadataViewUniversalIdentifiers: [], - fieldPermissionUniversalIdentifiers: [], - universalSettings: null, - viewSortUniversalIdentifiers: [], - searchFieldMetadataUniversalIdentifiers: [], - }; - - const searchVectorUniversalSettings: UniversalFlatFieldMetadata['universalSettings'] = - null; - - return { - fields: { - ...(nameField && { nameField }), - ...buildObjectSystemFlatFieldMetadatas({ - applicationUniversalIdentifier, - objectMetadataUniversalIdentifier, - now, - searchVectorUniversalSettings, - }), - }, - } as const satisfies { - fields: Record; - }; -}; diff --git a/packages/twenty-server/src/engine/metadata-modules/object-metadata/utils/build-default-index-for-custom-object.util.ts b/packages/twenty-server/src/engine/metadata-modules/object-metadata/utils/build-default-index-for-custom-object.util.ts deleted file mode 100644 index bae4cf6def..0000000000 --- a/packages/twenty-server/src/engine/metadata-modules/object-metadata/utils/build-default-index-for-custom-object.util.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { v4 } from 'uuid'; - -import { IndexType } from 'src/engine/metadata-modules/index-metadata/types/indexType.types'; -import { generateFlatIndexMetadataWithNameOrThrow } from 'src/engine/metadata-modules/index-metadata/utils/generate-flat-index.util'; -import { type DefaultFlatFieldForCustomObjectMaps } from 'src/engine/metadata-modules/object-metadata/utils/build-default-flat-field-metadatas-for-custom-object.util'; -import { type UniversalFlatFieldMetadata } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-field-metadata.type'; -import { type UniversalFlatIndexMetadata } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-index-metadata.type'; -import { type UniversalFlatObjectMetadata } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-object-metadata.type'; - -export const buildDefaultIndexesForCustomObject = ({ - flatObjectMetadata, - defaultFlatFieldForCustomObjectMaps, - objectFlatFieldMetadatas, -}: { - flatObjectMetadata: UniversalFlatObjectMetadata & { id: string }; - objectFlatFieldMetadatas: UniversalFlatFieldMetadata[]; - defaultFlatFieldForCustomObjectMaps: DefaultFlatFieldForCustomObjectMaps; -}) => { - const tsFlatVectorIndexUniversalIdentifier = v4(); - const createdAt = new Date(); - const tsVectorFlatIndex = generateFlatIndexMetadataWithNameOrThrow({ - objectFlatFieldMetadatas, - flatIndex: { - createdAt: createdAt.toISOString(), - universalFlatIndexFieldMetadatas: [ - { - createdAt: createdAt.toISOString(), - fieldMetadataUniversalIdentifier: - defaultFlatFieldForCustomObjectMaps.fields.searchVector - .universalIdentifier, - indexMetadataUniversalIdentifier: - tsFlatVectorIndexUniversalIdentifier, - order: 0, - subFieldName: null, - updatedAt: createdAt.toISOString(), - }, - ], - - indexType: IndexType.GIN, - indexWhereClause: null, - isCustom: false, - isUnique: false, - isSystemSideEffect: true, - objectMetadataUniversalIdentifier: flatObjectMetadata.universalIdentifier, - universalIdentifier: tsFlatVectorIndexUniversalIdentifier, - updatedAt: createdAt.toISOString(), - applicationUniversalIdentifier: - flatObjectMetadata.applicationUniversalIdentifier, - }, - flatObjectMetadata, - }); - - return { - indexes: { - tsVectorFlatIndex, - }, - } as const satisfies { indexes: Record }; -}; diff --git a/packages/twenty-server/src/engine/metadata-modules/object-metadata/utils/build-default-search-field-metadatas-for-custom-object.util.ts b/packages/twenty-server/src/engine/metadata-modules/object-metadata/utils/build-default-search-field-metadatas-for-custom-object.util.ts deleted file mode 100644 index 157c64bf65..0000000000 --- a/packages/twenty-server/src/engine/metadata-modules/object-metadata/utils/build-default-search-field-metadatas-for-custom-object.util.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { isDefined } from 'twenty-shared/utils'; - -import { buildFlatSearchFieldMetadataForField } from 'src/engine/metadata-modules/flat-search-field-metadata/utils/build-flat-search-field-metadata-for-field.util'; -import { type DefaultFlatFieldForCustomObjectMaps } from 'src/engine/metadata-modules/object-metadata/utils/build-default-flat-field-metadatas-for-custom-object.util'; -import { type UniversalFlatObjectMetadata } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-object-metadata.type'; -import { type UniversalFlatSearchFieldMetadata } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-search-field-metadata.type'; - -// Mirrors the custom-object searchVector, which indexes the name field only. Junction -// objects (skipNameField) have no name field and therefore get no row. -export const buildDefaultSearchFieldMetadatasForCustomObject = ({ - flatObjectMetadata, - defaultFlatFieldForCustomObjectMaps, -}: { - flatObjectMetadata: UniversalFlatObjectMetadata & { id: string }; - defaultFlatFieldForCustomObjectMaps: DefaultFlatFieldForCustomObjectMaps; -}): { - searchFieldMetadatas: UniversalFlatSearchFieldMetadata[]; -} => { - const nameField = defaultFlatFieldForCustomObjectMaps.fields.nameField; - - if (!isDefined(nameField)) { - return { - searchFieldMetadatas: [], - }; - } - - const nameSearchFieldMetadata = buildFlatSearchFieldMetadataForField({ - flatObjectMetadata, - flatFieldMetadata: nameField, - tsVectorFlatFieldMetadata: - defaultFlatFieldForCustomObjectMaps.fields.searchVector, - position: 0, - }); - - return { - searchFieldMetadatas: [nameSearchFieldMetadata], - }; -}; diff --git a/packages/twenty-server/src/engine/metadata-modules/object-metadata/utils/build-name-flat-field-metadata-for-custom-object.util.ts b/packages/twenty-server/src/engine/metadata-modules/object-metadata/utils/build-name-flat-field-metadata-for-custom-object.util.ts new file mode 100644 index 0000000000..016af839ae --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/object-metadata/utils/build-name-flat-field-metadata-for-custom-object.util.ts @@ -0,0 +1,60 @@ +import { getFieldUniversalIdentifier } from 'twenty-shared/application'; +import { FieldMetadataType } from 'twenty-shared/types'; + +import { type UniversalFlatFieldMetadata } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-field-metadata.type'; +import { type UniversalFlatObjectMetadata } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-object-metadata.type'; + +type BuildNameFlatFieldMetadataForCustomObjectArgs = { + flatObjectMetadata: Pick< + UniversalFlatObjectMetadata, + 'universalIdentifier' | 'applicationUniversalIdentifier' + >; +}; + +export const buildNameFlatFieldMetadataForCustomObject = ({ + flatObjectMetadata: { + applicationUniversalIdentifier, + universalIdentifier: objectMetadataUniversalIdentifier, + }, +}: BuildNameFlatFieldMetadataForCustomObjectArgs): UniversalFlatFieldMetadata => { + const now = new Date().toISOString(); + + return { + type: FieldMetadataType.TEXT, + isLabelSyncedWithName: false, + isUnique: false, + universalIdentifier: getFieldUniversalIdentifier({ + applicationUniversalIdentifier, + objectUniversalIdentifier: objectMetadataUniversalIdentifier, + name: 'name', + }), + name: 'name', + label: 'Name', + icon: 'IconAbc', + description: 'Name', + isNullable: true, + isActive: true, + isSystem: false, + isSystemSideEffect: false, + isUIEditable: true, + defaultValue: null, + createdAt: now, + updatedAt: now, + options: null, + overrides: null, + morphId: null, + applicationUniversalIdentifier, + objectMetadataUniversalIdentifier, + relationTargetObjectMetadataUniversalIdentifier: null, + relationTargetFieldMetadataUniversalIdentifier: null, + viewFilterUniversalIdentifiers: [], + viewFieldUniversalIdentifiers: [], + kanbanAggregateOperationViewUniversalIdentifiers: [], + calendarViewUniversalIdentifiers: [], + mainGroupByFieldMetadataViewUniversalIdentifiers: [], + fieldPermissionUniversalIdentifiers: [], + universalSettings: null, + viewSortUniversalIdentifiers: [], + searchFieldMetadataUniversalIdentifiers: [], + }; +}; diff --git a/packages/twenty-server/src/engine/metadata-modules/object-metadata/utils/build-reserved-system-flat-field-metadatas-for-custom-object.util.ts b/packages/twenty-server/src/engine/metadata-modules/object-metadata/utils/build-reserved-system-flat-field-metadatas-for-custom-object.util.ts new file mode 100644 index 0000000000..3960f663e1 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/object-metadata/utils/build-reserved-system-flat-field-metadatas-for-custom-object.util.ts @@ -0,0 +1,100 @@ +import { getFieldUniversalIdentifier } from 'twenty-shared/application'; + +import { PARTIAL_SYSTEM_FLAT_FIELD_METADATAS } from 'src/engine/metadata-modules/object-metadata/constants/partial-system-flat-field-metadatas.constant'; +import { type UniversalFlatFieldMetadata } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-field-metadata.type'; +import { type UniversalFlatObjectMetadata } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-object-metadata.type'; + +type BuildReservedSystemFlatFieldMetadatasForCustomObjectArgs = { + flatObjectMetadata: Pick< + UniversalFlatObjectMetadata, + 'universalIdentifier' | 'applicationUniversalIdentifier' + >; +}; + +export const buildReservedSystemFlatFieldMetadatasForCustomObject = ({ + flatObjectMetadata: { + applicationUniversalIdentifier, + universalIdentifier: objectMetadataUniversalIdentifier, + }, +}: BuildReservedSystemFlatFieldMetadatasForCustomObjectArgs): Record< + string, + UniversalFlatFieldMetadata +> => { + const now = new Date().toISOString(); + + const { + createdAt, + createdBy, + deletedAt, + id, + position, + updatedAt, + updatedBy, + } = PARTIAL_SYSTEM_FLAT_FIELD_METADATAS; + + const computeFieldUniversalIdentifier = (name: string) => + getFieldUniversalIdentifier({ + applicationUniversalIdentifier, + objectUniversalIdentifier: objectMetadataUniversalIdentifier, + name, + }); + + return { + id: { + ...id, + universalIdentifier: computeFieldUniversalIdentifier(id.name), + applicationUniversalIdentifier, + objectMetadataUniversalIdentifier, + createdAt: now, + updatedAt: now, + }, + createdAt: { + ...createdAt, + universalIdentifier: computeFieldUniversalIdentifier(createdAt.name), + applicationUniversalIdentifier, + objectMetadataUniversalIdentifier, + createdAt: now, + updatedAt: now, + }, + createdBy: { + ...createdBy, + universalIdentifier: computeFieldUniversalIdentifier(createdBy.name), + applicationUniversalIdentifier, + objectMetadataUniversalIdentifier, + createdAt: now, + updatedAt: now, + }, + deletedAt: { + ...deletedAt, + universalIdentifier: computeFieldUniversalIdentifier(deletedAt.name), + applicationUniversalIdentifier, + objectMetadataUniversalIdentifier, + createdAt: now, + updatedAt: now, + }, + position: { + ...position, + universalIdentifier: computeFieldUniversalIdentifier(position.name), + applicationUniversalIdentifier, + objectMetadataUniversalIdentifier, + createdAt: now, + updatedAt: now, + }, + updatedAt: { + ...updatedAt, + universalIdentifier: computeFieldUniversalIdentifier(updatedAt.name), + applicationUniversalIdentifier, + objectMetadataUniversalIdentifier, + createdAt: now, + updatedAt: now, + }, + updatedBy: { + ...updatedBy, + universalIdentifier: computeFieldUniversalIdentifier(updatedBy.name), + applicationUniversalIdentifier, + objectMetadataUniversalIdentifier, + createdAt: now, + updatedAt: now, + }, + } as const satisfies Record; +}; diff --git a/packages/twenty-server/src/engine/metadata-modules/object-metadata/utils/build-search-vector-flat-field-metadata-for-custom-object.util.ts b/packages/twenty-server/src/engine/metadata-modules/object-metadata/utils/build-search-vector-flat-field-metadata-for-custom-object.util.ts new file mode 100644 index 0000000000..53a53e550c --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/object-metadata/utils/build-search-vector-flat-field-metadata-for-custom-object.util.ts @@ -0,0 +1,38 @@ +import { getFieldUniversalIdentifier } from 'twenty-shared/application'; +import { type FieldMetadataType } from 'twenty-shared/types'; + +import { PARTIAL_SYSTEM_FLAT_FIELD_METADATAS } from 'src/engine/metadata-modules/object-metadata/constants/partial-system-flat-field-metadatas.constant'; +import { type UniversalFlatFieldMetadata } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-field-metadata.type'; +import { type UniversalFlatObjectMetadata } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-object-metadata.type'; + +type BuildSearchVectorFlatFieldMetadataForCustomObjectArgs = { + flatObjectMetadata: Pick< + UniversalFlatObjectMetadata, + 'universalIdentifier' | 'applicationUniversalIdentifier' + >; +}; + +export const buildSearchVectorFlatFieldMetadataForCustomObject = ({ + flatObjectMetadata: { + applicationUniversalIdentifier, + universalIdentifier: objectMetadataUniversalIdentifier, + }, +}: BuildSearchVectorFlatFieldMetadataForCustomObjectArgs): UniversalFlatFieldMetadata => { + const now = new Date().toISOString(); + + const { searchVector } = PARTIAL_SYSTEM_FLAT_FIELD_METADATAS; + + return { + ...searchVector, + universalIdentifier: getFieldUniversalIdentifier({ + applicationUniversalIdentifier, + objectUniversalIdentifier: objectMetadataUniversalIdentifier, + name: searchVector.name, + }), + applicationUniversalIdentifier, + objectMetadataUniversalIdentifier, + createdAt: now, + updatedAt: now, + universalSettings: null, + }; +}; diff --git a/packages/twenty-server/src/engine/metadata-modules/object-metadata/utils/build-search-vector-gin-index-for-custom-object.util.ts b/packages/twenty-server/src/engine/metadata-modules/object-metadata/utils/build-search-vector-gin-index-for-custom-object.util.ts new file mode 100644 index 0000000000..e21ba154df --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/object-metadata/utils/build-search-vector-gin-index-for-custom-object.util.ts @@ -0,0 +1,42 @@ +import { IndexType } from 'src/engine/metadata-modules/index-metadata/types/indexType.types'; +import { generateDeterministicFlatIndexMetadataOrThrow } from 'src/engine/metadata-modules/index-metadata/utils/generate-deterministic-flat-index.util'; +import { type UniversalFlatFieldMetadata } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-field-metadata.type'; +import { type UniversalFlatIndexMetadata } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-index-metadata.type'; +import { type UniversalFlatObjectMetadata } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-object-metadata.type'; + +export const buildSearchVectorGinIndexForCustomObject = ({ + flatObjectMetadata, + searchVectorFlatFieldMetadata, +}: { + flatObjectMetadata: UniversalFlatObjectMetadata; + searchVectorFlatFieldMetadata: UniversalFlatFieldMetadata; +}): UniversalFlatIndexMetadata => { + const createdAt = new Date().toISOString(); + + return generateDeterministicFlatIndexMetadataOrThrow({ + objectFlatFieldMetadatas: [searchVectorFlatFieldMetadata], + flatIndex: { + createdAt, + universalFlatIndexFieldMetadatas: [ + { + createdAt, + fieldMetadataUniversalIdentifier: + searchVectorFlatFieldMetadata.universalIdentifier, + order: 0, + subFieldName: null, + updatedAt: createdAt, + }, + ], + indexType: IndexType.GIN, + indexWhereClause: null, + isCustom: false, + isUnique: false, + isSystemSideEffect: true, + objectMetadataUniversalIdentifier: flatObjectMetadata.universalIdentifier, + updatedAt: createdAt, + applicationUniversalIdentifier: + flatObjectMetadata.applicationUniversalIdentifier, + }, + flatObjectMetadata, + }); +}; diff --git a/packages/twenty-server/src/engine/metadata-modules/object-metadata/utils/object-metadata-exception-code-to-http-status.util.ts b/packages/twenty-server/src/engine/metadata-modules/object-metadata/utils/object-metadata-exception-code-to-http-status.util.ts index 30a58f6504..08cb5a59ef 100644 --- a/packages/twenty-server/src/engine/metadata-modules/object-metadata/utils/object-metadata-exception-code-to-http-status.util.ts +++ b/packages/twenty-server/src/engine/metadata-modules/object-metadata/utils/object-metadata-exception-code-to-http-status.util.ts @@ -14,8 +14,6 @@ export const objectMetadataExceptionCodeToHttpStatus = ( case ObjectMetadataExceptionCode.NAME_CONFLICT: return 403; case ObjectMetadataExceptionCode.INVALID_OBJECT_INPUT: - case ObjectMetadataExceptionCode.MISSING_SYSTEM_FIELD: - case ObjectMetadataExceptionCode.INVALID_SYSTEM_FIELD: case ObjectMetadataExceptionCode.MISSING_CUSTOM_OBJECT_DEFAULT_LABEL_IDENTIFIER_FIELD: case ObjectMetadataExceptionCode.APPLICATION_NOT_FOUND: return 400; diff --git a/packages/twenty-server/src/engine/metadata-modules/object-metadata/utils/object-metadata-graphql-api-exception-handler.util.ts b/packages/twenty-server/src/engine/metadata-modules/object-metadata/utils/object-metadata-graphql-api-exception-handler.util.ts index 1a30ba65d1..66fcf24281 100644 --- a/packages/twenty-server/src/engine/metadata-modules/object-metadata/utils/object-metadata-graphql-api-exception-handler.util.ts +++ b/packages/twenty-server/src/engine/metadata-modules/object-metadata/utils/object-metadata-graphql-api-exception-handler.util.ts @@ -41,9 +41,6 @@ export const objectMetadataGraphqlApiExceptionHandler = (error: Error) => { case ObjectMetadataExceptionCode.MISSING_CUSTOM_OBJECT_DEFAULT_LABEL_IDENTIFIER_FIELD: case ObjectMetadataExceptionCode.APPLICATION_NOT_FOUND: throw error; - case ObjectMetadataExceptionCode.MISSING_SYSTEM_FIELD: - case ObjectMetadataExceptionCode.INVALID_SYSTEM_FIELD: - throw new UserInputError(error); default: { return assertUnreachable(error.code); } diff --git a/packages/twenty-server/src/engine/metadata-modules/search-field-metadata/search-field-metadata.entity.ts b/packages/twenty-server/src/engine/metadata-modules/search-field-metadata/search-field-metadata.entity.ts index d7dd374a95..3aca0c8a17 100644 --- a/packages/twenty-server/src/engine/metadata-modules/search-field-metadata/search-field-metadata.entity.ts +++ b/packages/twenty-server/src/engine/metadata-modules/search-field-metadata/search-field-metadata.entity.ts @@ -13,6 +13,7 @@ import { import { ADD_UNIVERSAL_IDENTIFIER_AND_APPLICATION_ID_TO_SEARCH_FIELD_METADATA_UPGRADE_COMMAND_NAME } from 'src/database/commands/upgrade-version-command/2-16/add-universal-identifier-and-application-id-to-search-field-metadata-upgrade-command-name.constant'; import { ADD_TS_VECTOR_FIELD_METADATA_ID_TO_SEARCH_FIELD_METADATA_UPGRADE_COMMAND_NAME } from 'src/database/commands/upgrade-version-command/2-18/add-ts-vector-field-metadata-id-to-search-field-metadata-upgrade-command-name.constant'; +import { ADD_IS_SYSTEM_SIDE_EFFECT_TO_SEARCH_FIELD_METADATA_UPGRADE_COMMAND_NAME } from 'src/database/commands/upgrade-version-command/2-20/add-is-system-side-effect-to-search-field-metadata-upgrade-command-name.constant'; import { WasIntroducedInUpgrade } from 'src/engine/core-modules/upgrade/decorators/was-introduced-in-upgrade.decorator'; import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; @@ -69,6 +70,13 @@ export class SearchFieldMetadataEntity extends SyncableEntity { @Column({ nullable: false, type: 'float' }) position: number; + @WasIntroducedInUpgrade({ + upgradeCommandName: + ADD_IS_SYSTEM_SIDE_EFFECT_TO_SEARCH_FIELD_METADATA_UPGRADE_COMMAND_NAME, + }) + @Column({ nullable: false, default: true, type: 'boolean' }) + isSystemSideEffect: boolean; + @CreateDateColumn({ type: 'timestamptz' }) createdAt: Date; diff --git a/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/index/compute-company-standard-flat-index-metadata.util.ts b/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/index/compute-company-standard-flat-index-metadata.util.ts index d8cd47ee7c..f72d78412c 100644 --- a/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/index/compute-company-standard-flat-index-metadata.util.ts +++ b/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/index/compute-company-standard-flat-index-metadata.util.ts @@ -50,6 +50,7 @@ export const buildCompanyStandardFlatIndexMetadatas = ({ indexName: 'searchVectorGinIndex', relatedFieldNames: ['searchVector'], indexType: IndexType.GIN, + hasDeterministicUniversalIdentifier: true, }, standardObjectMetadataRelatedEntityIds, dependencyFlatEntityMaps, diff --git a/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/index/compute-dashboard-standard-flat-index-metadata.util.ts b/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/index/compute-dashboard-standard-flat-index-metadata.util.ts index 6460523fda..da5b78a6ab 100644 --- a/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/index/compute-dashboard-standard-flat-index-metadata.util.ts +++ b/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/index/compute-dashboard-standard-flat-index-metadata.util.ts @@ -24,6 +24,7 @@ export const buildDashboardStandardFlatIndexMetadatas = ({ indexName: 'searchVectorGinIndex', relatedFieldNames: ['searchVector'], indexType: IndexType.GIN, + hasDeterministicUniversalIdentifier: true, }, standardObjectMetadataRelatedEntityIds, dependencyFlatEntityMaps, diff --git a/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/index/compute-message-campaign-standard-flat-index-metadata.util.ts b/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/index/compute-message-campaign-standard-flat-index-metadata.util.ts index f7414e52bc..57c2bd5a15 100644 --- a/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/index/compute-message-campaign-standard-flat-index-metadata.util.ts +++ b/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/index/compute-message-campaign-standard-flat-index-metadata.util.ts @@ -48,6 +48,7 @@ export const buildMessageCampaignStandardFlatIndexMetadatas = ({ indexName: 'searchVectorGinIndex', relatedFieldNames: ['searchVector'], indexType: IndexType.GIN, + hasDeterministicUniversalIdentifier: true, }, standardObjectMetadataRelatedEntityIds, dependencyFlatEntityMaps, diff --git a/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/index/compute-message-list-standard-flat-index-metadata.util.ts b/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/index/compute-message-list-standard-flat-index-metadata.util.ts index b7114db8d6..f5fafebdc1 100644 --- a/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/index/compute-message-list-standard-flat-index-metadata.util.ts +++ b/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/index/compute-message-list-standard-flat-index-metadata.util.ts @@ -24,6 +24,7 @@ export const buildMessageListStandardFlatIndexMetadatas = ({ indexName: 'searchVectorGinIndex', relatedFieldNames: ['searchVector'], indexType: IndexType.GIN, + hasDeterministicUniversalIdentifier: true, }, standardObjectMetadataRelatedEntityIds, dependencyFlatEntityMaps, diff --git a/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/index/compute-note-standard-flat-index-metadata.util.ts b/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/index/compute-note-standard-flat-index-metadata.util.ts index cd0c6fb980..9ea2998e7a 100644 --- a/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/index/compute-note-standard-flat-index-metadata.util.ts +++ b/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/index/compute-note-standard-flat-index-metadata.util.ts @@ -24,6 +24,7 @@ export const buildNoteStandardFlatIndexMetadatas = ({ indexName: 'searchVectorGinIndex', relatedFieldNames: ['searchVector'], indexType: IndexType.GIN, + hasDeterministicUniversalIdentifier: true, }, standardObjectMetadataRelatedEntityIds, dependencyFlatEntityMaps, diff --git a/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/index/compute-opportunity-standard-flat-index-metadata.util.ts b/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/index/compute-opportunity-standard-flat-index-metadata.util.ts index a029a59fc6..4365895b4c 100644 --- a/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/index/compute-opportunity-standard-flat-index-metadata.util.ts +++ b/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/index/compute-opportunity-standard-flat-index-metadata.util.ts @@ -60,6 +60,7 @@ export const buildOpportunityStandardFlatIndexMetadatas = ({ indexName: 'searchVectorGinIndex', relatedFieldNames: ['searchVector'], indexType: IndexType.GIN, + hasDeterministicUniversalIdentifier: true, }, standardObjectMetadataRelatedEntityIds, dependencyFlatEntityMaps, diff --git a/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/index/compute-person-standard-flat-index-metadata.util.ts b/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/index/compute-person-standard-flat-index-metadata.util.ts index 3e2422e1d2..2748193e8b 100644 --- a/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/index/compute-person-standard-flat-index-metadata.util.ts +++ b/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/index/compute-person-standard-flat-index-metadata.util.ts @@ -50,6 +50,7 @@ export const buildPersonStandardFlatIndexMetadatas = ({ indexName: 'searchVectorGinIndex', relatedFieldNames: ['searchVector'], indexType: IndexType.GIN, + hasDeterministicUniversalIdentifier: true, }, standardObjectMetadataRelatedEntityIds, dependencyFlatEntityMaps, diff --git a/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/index/compute-task-standard-flat-index-metadata.util.ts b/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/index/compute-task-standard-flat-index-metadata.util.ts index 783c2d6a12..f22e0330fc 100644 --- a/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/index/compute-task-standard-flat-index-metadata.util.ts +++ b/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/index/compute-task-standard-flat-index-metadata.util.ts @@ -36,6 +36,7 @@ export const buildTaskStandardFlatIndexMetadatas = ({ indexName: 'searchVectorGinIndex', relatedFieldNames: ['searchVector'], indexType: IndexType.GIN, + hasDeterministicUniversalIdentifier: true, }, standardObjectMetadataRelatedEntityIds, dependencyFlatEntityMaps, diff --git a/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/index/compute-workflow-run-standard-flat-index-metadata.util.ts b/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/index/compute-workflow-run-standard-flat-index-metadata.util.ts index a0f6d06b08..1e3ba1ea78 100644 --- a/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/index/compute-workflow-run-standard-flat-index-metadata.util.ts +++ b/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/index/compute-workflow-run-standard-flat-index-metadata.util.ts @@ -48,6 +48,7 @@ export const buildWorkflowRunStandardFlatIndexMetadatas = ({ indexName: 'searchVectorGinIndex', relatedFieldNames: ['searchVector'], indexType: IndexType.GIN, + hasDeterministicUniversalIdentifier: true, }, standardObjectMetadataRelatedEntityIds, dependencyFlatEntityMaps, diff --git a/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/index/compute-workflow-standard-flat-index-metadata.util.ts b/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/index/compute-workflow-standard-flat-index-metadata.util.ts index 1619256873..f4a5a3b6b7 100644 --- a/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/index/compute-workflow-standard-flat-index-metadata.util.ts +++ b/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/index/compute-workflow-standard-flat-index-metadata.util.ts @@ -24,6 +24,7 @@ export const buildWorkflowStandardFlatIndexMetadatas = ({ indexName: 'searchVectorGinIndex', relatedFieldNames: ['searchVector'], indexType: IndexType.GIN, + hasDeterministicUniversalIdentifier: true, }, standardObjectMetadataRelatedEntityIds, dependencyFlatEntityMaps, diff --git a/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/index/compute-workflow-version-standard-flat-index-metadata.util.ts b/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/index/compute-workflow-version-standard-flat-index-metadata.util.ts index fbfc37afb0..2cee7bc6cd 100644 --- a/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/index/compute-workflow-version-standard-flat-index-metadata.util.ts +++ b/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/index/compute-workflow-version-standard-flat-index-metadata.util.ts @@ -36,6 +36,7 @@ export const buildWorkflowVersionStandardFlatIndexMetadatas = ({ indexName: 'searchVectorGinIndex', relatedFieldNames: ['searchVector'], indexType: IndexType.GIN, + hasDeterministicUniversalIdentifier: true, }, standardObjectMetadataRelatedEntityIds, dependencyFlatEntityMaps, diff --git a/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/index/compute-workspace-member-standard-flat-index-metadata.util.ts b/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/index/compute-workspace-member-standard-flat-index-metadata.util.ts index a1a24ec05f..31c2e56c13 100644 --- a/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/index/compute-workspace-member-standard-flat-index-metadata.util.ts +++ b/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/index/compute-workspace-member-standard-flat-index-metadata.util.ts @@ -38,6 +38,7 @@ export const buildWorkspaceMemberStandardFlatIndexMetadatas = ({ indexName: 'searchVectorGinIndex', relatedFieldNames: ['searchVector'], indexType: IndexType.GIN, + hasDeterministicUniversalIdentifier: true, }, standardObjectMetadataRelatedEntityIds, dependencyFlatEntityMaps, diff --git a/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/search-field-metadata/create-standard-search-field-flat-metadata.util.ts b/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/search-field-metadata/create-standard-search-field-flat-metadata.util.ts index 03e9254a68..195db96554 100644 --- a/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/search-field-metadata/create-standard-search-field-flat-metadata.util.ts +++ b/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/search-field-metadata/create-standard-search-field-flat-metadata.util.ts @@ -1,3 +1,4 @@ +import { getSearchFieldUniversalIdentifier } from 'twenty-shared/application'; import { STANDARD_OBJECTS } from 'twenty-shared/metadata'; import { v4 } from 'uuid'; @@ -67,7 +68,11 @@ export const createStandardSearchFieldFlatMetadata = < return { id: v4(), - universalIdentifier: v4(), + universalIdentifier: getSearchFieldUniversalIdentifier({ + applicationUniversalIdentifier: + flatObjectMetadata.applicationUniversalIdentifier, + fieldMetadataUniversalIdentifier: flatFieldMetadata.universalIdentifier, + }), applicationId: twentyStandardApplicationId, applicationUniversalIdentifier: flatObjectMetadata.applicationUniversalIdentifier, @@ -78,6 +83,7 @@ export const createStandardSearchFieldFlatMetadata = < tsVectorFieldMetadataId, tsVectorFieldMetadataUniversalIdentifier, position, + isSystemSideEffect: true, workspaceId, createdAt: now, updatedAt: now, diff --git a/packages/twenty-server/test/integration/metadata/suites/application/__snapshots__/failing-sync-application-object-system-fields.integration-spec.ts.snap b/packages/twenty-server/test/integration/metadata/suites/application/__snapshots__/failing-sync-application-object-system-fields.integration-spec.ts.snap deleted file mode 100644 index aaf1a62079..0000000000 --- a/packages/twenty-server/test/integration/metadata/suites/application/__snapshots__/failing-sync-application-object-system-fields.integration-spec.ts.snap +++ /dev/null @@ -1,577 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Sync application should fail due to object system fields integrity should fail when trying to delete a system field after a successful sync 1`] = ` -{ - "extensions": { - "code": "METADATA_VALIDATION_FAILED", - "errors": { - "fieldMetadata": [ - { - "errors": [ - { - "code": "FIELD_MUTATION_NOT_ALLOWED", - "message": "System fields cannot be deleted", - "userFriendlyMessage": "System fields cannot be deleted", - }, - ], - "flatEntityMinimalInformation": { - "name": "id", - "objectMetadataUniversalIdentifier": Any, - "universalIdentifier": Any, - }, - "metadataName": "fieldMetadata", - "status": "fail", - "type": "delete", - }, - ], - }, - "message": "Validation failed for 1 fieldMetadata", - "summary": { - "fieldMetadata": 1, - "totalErrors": 1, - }, - "userFriendlyMessage": "System fields cannot be deleted", - }, - "message": "Validation errors occurred while syncing application manifest metadata", - "name": "GraphQLError", -} -`; - -exports[`Sync application should fail due to object system fields integrity should fail when trying to update a system field after a successful sync 1`] = ` -{ - "extensions": { - "code": "METADATA_VALIDATION_FAILED", - "errors": { - "fieldMetadata": [ - { - "errors": [ - { - "code": "FIELD_MUTATION_NOT_ALLOWED", - "message": "System fields only allow updating: universalSettings, isActive. Forbidden properties: label", - "userFriendlyMessage": "System fields cannot be updated", - }, - ], - "flatEntityMinimalInformation": { - "name": "id", - "objectMetadataUniversalIdentifier": Any, - "universalIdentifier": Any, - }, - "metadataName": "fieldMetadata", - "status": "fail", - "type": "update", - }, - ], - }, - "message": "Validation failed for 1 fieldMetadata", - "summary": { - "fieldMetadata": 1, - "totalErrors": 1, - }, - "userFriendlyMessage": "System fields cannot be updated", - }, - "message": "Validation errors occurred while syncing application manifest metadata", - "name": "GraphQLError", -} -`; - -exports[`Sync application should fail due to object system fields integrity when label identifier is non-searchable type (searchVector has no expression) 1`] = ` -{ - "extensions": { - "code": "METADATA_VALIDATION_FAILED", - "errors": { - "objectMetadata": [ - { - "errors": [ - { - "code": "INVALID_OBJECT_INPUT", - "message": "labelIdentifierFieldMetadataUniversalIdentifier validation failed: field type not compatible", - "userFriendlyMessage": "Field cannot be used as label identifier due to its type: should be of type UUID, text or full name", - }, - ], - "flatEntityMinimalInformation": { - "namePlural": "noSearchVectorExpressions", - "nameSingular": "noSearchVectorExpression", - "universalIdentifier": Any, - }, - "metadataName": "objectMetadata", - "type": "create", - }, - ], - }, - "message": "Validation failed for 1 objectMetadata", - "summary": { - "objectMetadata": 1, - "totalErrors": 1, - }, - "userFriendlyMessage": "Field cannot be used as label identifier due to its type: should be of type UUID, text or full name", - }, - "message": "Validation errors occurred while syncing application manifest metadata", - "name": "GraphQLError", -} -`; - -exports[`Sync application should fail due to object system fields integrity when object has TS_VECTOR field with wrong name (not searchVector) 1`] = ` -{ - "extensions": { - "code": "METADATA_VALIDATION_FAILED", - "errors": { - "fieldMetadata": [ - { - "errors": [ - { - "code": "INVALID_FIELD_INPUT", - "message": "Field type TS_VECTOR must be named "searchVector", got "wrongSearchVector"", - "userFriendlyMessage": "Field type TS_VECTOR must be named "searchVector"", - "value": "wrongSearchVector", - }, - { - "code": "INVALID_FIELD_INPUT", - "message": "Field type TS_VECTOR must be a system field", - "userFriendlyMessage": "Field type TS_VECTOR must be a system field", - "value": false, - }, - ], - "flatEntityMinimalInformation": { - "name": "wrongSearchVector", - "objectMetadataUniversalIdentifier": Any, - "universalIdentifier": Any, - }, - "metadataName": "fieldMetadata", - "status": "fail", - "type": "create", - }, - ], - "objectMetadata": [ - { - "errors": [ - { - "code": "MISSING_SYSTEM_FIELD", - "message": "System field searchVector is missing", - "userFriendlyMessage": "System field searchVector is missing", - "value": "searchVector", - }, - ], - "flatEntityMinimalInformation": { - "namePlural": "wrongTsVectorNames", - "nameSingular": "wrongTsVectorName", - "universalIdentifier": Any, - }, - "metadataName": "objectMetadata", - "type": "create", - }, - ], - }, - "message": "Validation failed for 1 fieldMetadata, 1 objectMetadata", - "summary": { - "fieldMetadata": 1, - "objectMetadata": 1, - "totalErrors": 2, - }, - "userFriendlyMessage": "Many validation errors", - }, - "message": "Validation errors occurred while syncing application manifest metadata", - "name": "GraphQLError", -} -`; - -exports[`Sync application should fail due to object system fields integrity when object has a system field with a custom (non-derived) universal identifier 1`] = ` -{ - "extensions": { - "code": "METADATA_VALIDATION_FAILED", - "errors": { - "objectMetadata": [ - { - "errors": [ - { - "code": "INVALID_SYSTEM_FIELD", - "message": "System field createdAt has invalid universalIdentifier: expected 62e9d388-38c0-5cd8-b18d-db186455a563, got 899ac540-3a1f-42cf-9f99-8a55e79d0d9e", - "userFriendlyMessage": "System field createdAt universal identifier is not deterministic; it is derived by the server and cannot be customized", - "value": "899ac540-3a1f-42cf-9f99-8a55e79d0d9e", - }, - ], - "flatEntityMinimalInformation": { - "namePlural": "customSystemFieldUidObjects", - "nameSingular": "customSystemFieldUidObject", - "universalIdentifier": Any, - }, - "metadataName": "objectMetadata", - "type": "create", - }, - ], - }, - "message": "Validation failed for 1 objectMetadata", - "summary": { - "objectMetadata": 1, - "totalErrors": 1, - }, - "userFriendlyMessage": "System field createdAt universal identifier is not deterministic; it is derived by the server and cannot be customized", - }, - "message": "Validation errors occurred while syncing application manifest metadata", - "name": "GraphQLError", -} -`; - -exports[`Sync application should fail due to object system fields integrity when object has id field with wrong type (TEXT instead of UUID) 1`] = ` -{ - "extensions": { - "code": "METADATA_VALIDATION_FAILED", - "errors": { - "objectMetadata": [ - { - "errors": [ - { - "code": "INVALID_SYSTEM_FIELD", - "message": "System field id has invalid type: expected UUID, got TEXT", - "userFriendlyMessage": "System field id has invalid type", - "value": "TEXT", - }, - { - "code": "INVALID_SYSTEM_FIELD", - "message": "System field id has invalid universalIdentifier: expected 64e60b60-2bc9-5eac-a98b-83c1d628e326, got 4254b620-34f1-57d1-a155-527015f523db", - "userFriendlyMessage": "System field id universal identifier is not deterministic; it is derived by the server and cannot be customized", - "value": "4254b620-34f1-57d1-a155-527015f523db", - }, - { - "code": "MISSING_SYSTEM_FIELD", - "message": "System field createdAt is missing", - "userFriendlyMessage": "System field createdAt is missing", - "value": "createdAt", - }, - { - "code": "MISSING_SYSTEM_FIELD", - "message": "System field updatedAt is missing", - "userFriendlyMessage": "System field updatedAt is missing", - "value": "updatedAt", - }, - { - "code": "MISSING_SYSTEM_FIELD", - "message": "System field deletedAt is missing", - "userFriendlyMessage": "System field deletedAt is missing", - "value": "deletedAt", - }, - { - "code": "MISSING_SYSTEM_FIELD", - "message": "System field createdBy is missing", - "userFriendlyMessage": "System field createdBy is missing", - "value": "createdBy", - }, - { - "code": "MISSING_SYSTEM_FIELD", - "message": "System field updatedBy is missing", - "userFriendlyMessage": "System field updatedBy is missing", - "value": "updatedBy", - }, - { - "code": "MISSING_SYSTEM_FIELD", - "message": "System field position is missing", - "userFriendlyMessage": "System field position is missing", - "value": "position", - }, - { - "code": "MISSING_SYSTEM_FIELD", - "message": "System field searchVector is missing", - "userFriendlyMessage": "System field searchVector is missing", - "value": "searchVector", - }, - ], - "flatEntityMinimalInformation": { - "namePlural": "wrongIdTypeObjects", - "nameSingular": "wrongIdTypeObject", - "universalIdentifier": Any, - }, - "metadataName": "objectMetadata", - "type": "create", - }, - ], - }, - "message": "Validation failed for 1 objectMetadata", - "summary": { - "objectMetadata": 1, - "totalErrors": 1, - }, - "userFriendlyMessage": "System field id has invalid type", - }, - "message": "Validation errors occurred while syncing application manifest metadata", - "name": "GraphQLError", -} -`; - -exports[`Sync application should fail due to object system fields integrity when object has position field with wrong type (TEXT instead of POSITION) 1`] = ` -{ - "extensions": { - "code": "METADATA_VALIDATION_FAILED", - "errors": { - "objectMetadata": [ - { - "errors": [ - { - "code": "MISSING_SYSTEM_FIELD", - "message": "System field id is missing", - "userFriendlyMessage": "System field id is missing", - "value": "id", - }, - { - "code": "MISSING_SYSTEM_FIELD", - "message": "System field createdAt is missing", - "userFriendlyMessage": "System field createdAt is missing", - "value": "createdAt", - }, - { - "code": "MISSING_SYSTEM_FIELD", - "message": "System field updatedAt is missing", - "userFriendlyMessage": "System field updatedAt is missing", - "value": "updatedAt", - }, - { - "code": "MISSING_SYSTEM_FIELD", - "message": "System field deletedAt is missing", - "userFriendlyMessage": "System field deletedAt is missing", - "value": "deletedAt", - }, - { - "code": "MISSING_SYSTEM_FIELD", - "message": "System field createdBy is missing", - "userFriendlyMessage": "System field createdBy is missing", - "value": "createdBy", - }, - { - "code": "MISSING_SYSTEM_FIELD", - "message": "System field updatedBy is missing", - "userFriendlyMessage": "System field updatedBy is missing", - "value": "updatedBy", - }, - { - "code": "INVALID_SYSTEM_FIELD", - "message": "System field position has invalid type: expected POSITION, got TEXT", - "userFriendlyMessage": "System field position has invalid type", - "value": "TEXT", - }, - { - "code": "INVALID_SYSTEM_FIELD", - "message": "System field position has invalid universalIdentifier: expected dbd9f170-1d32-5267-b76e-e1f172ab617a, got 65eb86b6-1b74-58bd-8c02-7f79b018359d", - "userFriendlyMessage": "System field position universal identifier is not deterministic; it is derived by the server and cannot be customized", - "value": "65eb86b6-1b74-58bd-8c02-7f79b018359d", - }, - { - "code": "MISSING_SYSTEM_FIELD", - "message": "System field searchVector is missing", - "userFriendlyMessage": "System field searchVector is missing", - "value": "searchVector", - }, - ], - "flatEntityMinimalInformation": { - "namePlural": "wrongPositionObjects", - "nameSingular": "wrongPositionObject", - "universalIdentifier": Any, - }, - "metadataName": "objectMetadata", - "type": "create", - }, - ], - }, - "message": "Validation failed for 1 objectMetadata", - "summary": { - "objectMetadata": 1, - "totalErrors": 1, - }, - "userFriendlyMessage": "System field id is missing", - }, - "message": "Validation errors occurred while syncing application manifest metadata", - "name": "GraphQLError", -} -`; - -exports[`Sync application should fail due to object system fields integrity when object has searchVector field with wrong type (TEXT instead of TS_VECTOR) 1`] = ` -{ - "extensions": { - "code": "METADATA_VALIDATION_FAILED", - "errors": { - "objectMetadata": [ - { - "errors": [ - { - "code": "INVALID_SYSTEM_FIELD", - "message": "System field searchVector has invalid type: expected TS_VECTOR, got TEXT", - "userFriendlyMessage": "System field searchVector has invalid type", - "value": "TEXT", - }, - ], - "flatEntityMinimalInformation": { - "namePlural": "wrongSearchVectorTypes", - "nameSingular": "wrongSearchVectorType", - "universalIdentifier": Any, - }, - "metadataName": "objectMetadata", - "type": "create", - }, - ], - }, - "message": "Validation failed for 1 objectMetadata", - "summary": { - "objectMetadata": 1, - "totalErrors": 1, - }, - "userFriendlyMessage": "System field searchVector has invalid type", - }, - "message": "Validation errors occurred while syncing application manifest metadata", - "name": "GraphQLError", -} -`; - -exports[`Sync application should fail due to object system fields integrity when object is created without any system fields (missing all 8 system fields) 1`] = ` -{ - "extensions": { - "code": "METADATA_VALIDATION_FAILED", - "errors": { - "objectMetadata": [ - { - "errors": [ - { - "code": "MISSING_SYSTEM_FIELD", - "message": "System field id is missing", - "userFriendlyMessage": "System field id is missing", - "value": "id", - }, - { - "code": "MISSING_SYSTEM_FIELD", - "message": "System field createdAt is missing", - "userFriendlyMessage": "System field createdAt is missing", - "value": "createdAt", - }, - { - "code": "MISSING_SYSTEM_FIELD", - "message": "System field updatedAt is missing", - "userFriendlyMessage": "System field updatedAt is missing", - "value": "updatedAt", - }, - { - "code": "MISSING_SYSTEM_FIELD", - "message": "System field deletedAt is missing", - "userFriendlyMessage": "System field deletedAt is missing", - "value": "deletedAt", - }, - { - "code": "MISSING_SYSTEM_FIELD", - "message": "System field createdBy is missing", - "userFriendlyMessage": "System field createdBy is missing", - "value": "createdBy", - }, - { - "code": "MISSING_SYSTEM_FIELD", - "message": "System field updatedBy is missing", - "userFriendlyMessage": "System field updatedBy is missing", - "value": "updatedBy", - }, - { - "code": "MISSING_SYSTEM_FIELD", - "message": "System field position is missing", - "userFriendlyMessage": "System field position is missing", - "value": "position", - }, - { - "code": "MISSING_SYSTEM_FIELD", - "message": "System field searchVector is missing", - "userFriendlyMessage": "System field searchVector is missing", - "value": "searchVector", - }, - ], - "flatEntityMinimalInformation": { - "namePlural": "noSystemFieldsObjects", - "nameSingular": "noSystemFieldsObject", - "universalIdentifier": Any, - }, - "metadataName": "objectMetadata", - "type": "create", - }, - ], - }, - "message": "Validation failed for 1 objectMetadata", - "summary": { - "objectMetadata": 1, - "totalErrors": 1, - }, - "userFriendlyMessage": "System field id is missing", - }, - "message": "Validation errors occurred while syncing application manifest metadata", - "name": "GraphQLError", -} -`; - -exports[`Sync application should fail due to object system fields integrity when object miss default fields 1`] = ` -{ - "extensions": { - "code": "METADATA_VALIDATION_FAILED", - "errors": { - "objectMetadata": [ - { - "errors": [ - { - "code": "MISSING_SYSTEM_FIELD", - "message": "System field id is missing", - "userFriendlyMessage": "System field id is missing", - "value": "id", - }, - { - "code": "MISSING_SYSTEM_FIELD", - "message": "System field createdAt is missing", - "userFriendlyMessage": "System field createdAt is missing", - "value": "createdAt", - }, - { - "code": "MISSING_SYSTEM_FIELD", - "message": "System field updatedAt is missing", - "userFriendlyMessage": "System field updatedAt is missing", - "value": "updatedAt", - }, - { - "code": "MISSING_SYSTEM_FIELD", - "message": "System field deletedAt is missing", - "userFriendlyMessage": "System field deletedAt is missing", - "value": "deletedAt", - }, - { - "code": "MISSING_SYSTEM_FIELD", - "message": "System field createdBy is missing", - "userFriendlyMessage": "System field createdBy is missing", - "value": "createdBy", - }, - { - "code": "MISSING_SYSTEM_FIELD", - "message": "System field updatedBy is missing", - "userFriendlyMessage": "System field updatedBy is missing", - "value": "updatedBy", - }, - { - "code": "MISSING_SYSTEM_FIELD", - "message": "System field position is missing", - "userFriendlyMessage": "System field position is missing", - "value": "position", - }, - { - "code": "MISSING_SYSTEM_FIELD", - "message": "System field searchVector is missing", - "userFriendlyMessage": "System field searchVector is missing", - "value": "searchVector", - }, - ], - "flatEntityMinimalInformation": { - "namePlural": "wrongCreatedAtObjects", - "nameSingular": "wrongCreatedAtObject", - "universalIdentifier": Any, - }, - "metadataName": "objectMetadata", - "type": "create", - }, - ], - }, - "message": "Validation failed for 1 objectMetadata", - "summary": { - "objectMetadata": 1, - "totalErrors": 1, - }, - "userFriendlyMessage": "System field id is missing", - }, - "message": "Validation errors occurred while syncing application manifest metadata", - "name": "GraphQLError", -} -`; diff --git a/packages/twenty-server/test/integration/metadata/suites/application/__snapshots__/successful-sync-application-workspace-migration.integration-spec.ts.snap b/packages/twenty-server/test/integration/metadata/suites/application/__snapshots__/successful-sync-application-workspace-migration.integration-spec.ts.snap index 09eda27881..1464da1f2d 100644 --- a/packages/twenty-server/test/integration/metadata/suites/application/__snapshots__/successful-sync-application-workspace-migration.integration-spec.ts.snap +++ b/packages/twenty-server/test/integration/metadata/suites/application/__snapshots__/successful-sync-application-workspace-migration.integration-spec.ts.snap @@ -347,6 +347,33 @@ exports[`syncApplication should delete old field and create equivalent one when }, ], }, + { + "flatEntity": { + "applicationUniversalIdentifier": Any, + "createdAt": Any, + "indexType": "GIN", + "indexWhereClause": null, + "isCustom": false, + "isSystemSideEffect": true, + "isUnique": false, + "name": "IDX_72b74c26016bcd3cd20b85c4a28", + "objectMetadataUniversalIdentifier": Any, + "universalFlatIndexFieldMetadatas": [ + { + "createdAt": Any, + "fieldMetadataUniversalIdentifier": Any, + "indexMetadataUniversalIdentifier": Any, + "order": 0, + "subFieldName": null, + "updatedAt": Any, + }, + ], + "universalIdentifier": Any, + "updatedAt": Any, + }, + "metadataName": "index", + "type": "create", + }, { "flatEntity": { "applicationUniversalIdentifier": Any, @@ -754,6 +781,33 @@ exports[`syncApplication should return workspace migration actions on initial sy }, ], }, + { + "flatEntity": { + "applicationUniversalIdentifier": Any, + "createdAt": Any, + "indexType": "GIN", + "indexWhereClause": null, + "isCustom": false, + "isSystemSideEffect": true, + "isUnique": false, + "name": "IDX_72b74c26016bcd3cd20b85c4a28", + "objectMetadataUniversalIdentifier": Any, + "universalFlatIndexFieldMetadatas": [ + { + "createdAt": Any, + "fieldMetadataUniversalIdentifier": Any, + "indexMetadataUniversalIdentifier": Any, + "order": 0, + "subFieldName": null, + "updatedAt": Any, + }, + ], + "universalIdentifier": Any, + "updatedAt": Any, + }, + "metadataName": "index", + "type": "create", + }, { "flatEntity": { "applicationUniversalIdentifier": Any, diff --git a/packages/twenty-server/test/integration/metadata/suites/application/failing-sync-application-object-system-fields.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/application/failing-sync-application-object-system-fields.integration-spec.ts deleted file mode 100644 index 992cf926ea..0000000000 --- a/packages/twenty-server/test/integration/metadata/suites/application/failing-sync-application-object-system-fields.integration-spec.ts +++ /dev/null @@ -1,460 +0,0 @@ -import { expectOneNotInternalServerErrorSnapshot } from 'test/integration/graphql/utils/expect-one-not-internal-server-error-snapshot.util'; -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 { type Manifest, type ObjectManifest } from 'twenty-shared/application'; -import { - type EachTestingContext, - eachTestingContextFilter, -} from 'twenty-shared/testing'; -import { FieldMetadataType } from 'twenty-shared/types'; -import { v4 as uuidv4, v5 as uuidv5 } from 'uuid'; - -// Identifiers are pinned so validation error messages embedding expected and -// actual universal identifiers stay stable across snapshot runs. -const TEST_APP_ID = '4e0e42a8-8f9c-4a48-9d43-5e0c5c2f4a10'; -const TEST_ROLE_ID = 'd0a24fbc-4b26-42a5-a4ff-2e142f8e2f6d'; -const TEST_UUID_NAMESPACE = '6a9c8f74-4b7a-4a86-90f4-2f4dbb1c2a30'; - -const computeDeterministicTestUuid = (seed: string) => - uuidv5(seed, TEST_UUID_NAMESPACE); - -type TestContext = { - manifest: Manifest; -}; - -type SyncApplicationTestingContext = EachTestingContext[]; - -const buildManifest = (overrides: Pick) => - buildBaseManifest({ - appId: TEST_APP_ID, - roleId: TEST_ROLE_ID, - overrides, - }); - -const buildObjectWithLabelField = ({ - nameSingular, - namePlural, - labelSingular, - labelPlural, - description, - additionalFields = [], -}: { - nameSingular: string; - namePlural: string; - labelSingular: string; - labelPlural: string; - description: string; - additionalFields?: ObjectManifest['fields']; -}): Pick => { - const objectId = computeDeterministicTestUuid(nameSingular); - const labelFieldId = computeDeterministicTestUuid(`${nameSingular}-title`); - - return { - objects: [ - { - universalIdentifier: objectId, - labelIdentifierFieldMetadataUniversalIdentifier: labelFieldId, - nameSingular, - namePlural, - labelSingular, - labelPlural, - description, - icon: 'IconTicket', - fields: [ - { - universalIdentifier: labelFieldId, - type: FieldMetadataType.TEXT, - name: 'title', - label: 'Title', - description: 'Label identifier field', - icon: 'IconTextCaption', - }, - ...additionalFields, - ], - }, - ], - fields: [], - }; -}; - -const buildDefaultObjectWithModifiedSearchVector = ({ - nameSingular, - namePlural, - labelSingular, - labelPlural, - description, - searchVectorOverrides, -}: { - nameSingular: string; - namePlural: string; - labelSingular: string; - labelPlural: string; - description: string; - searchVectorOverrides: Partial; -}): Pick => { - const defaultObject = buildDefaultObjectManifest({ - applicationUniversalIdentifier: TEST_APP_ID, - universalIdentifier: computeDeterministicTestUuid(nameSingular), - nameSingular, - namePlural, - labelSingular, - labelPlural, - description, - }); - - return { - objects: [ - { - ...defaultObject, - fields: defaultObject.fields.map((field) => - field.name === 'searchVector' - ? ({ - ...field, - ...searchVectorOverrides, - } as (typeof defaultObject.fields)[number]) - : field, - ), - }, - ], - fields: [], - }; -}; - -const failingSyncApplicationSystemFieldsTestCases: SyncApplicationTestingContext = - [ - { - title: - 'when object is created without any system fields (missing all 8 system fields)', - context: { - manifest: buildManifest( - buildObjectWithLabelField({ - nameSingular: 'noSystemFieldsObject', - namePlural: 'noSystemFieldsObjects', - labelSingular: 'No System Fields Object', - labelPlural: 'No System Fields Objects', - description: 'Object with no system fields', - }), - ), - }, - }, - { - title: 'when object has id field with wrong type (TEXT instead of UUID)', - context: { - manifest: buildManifest( - buildObjectWithLabelField({ - nameSingular: 'wrongIdTypeObject', - namePlural: 'wrongIdTypeObjects', - labelSingular: 'Wrong Id Type Object', - labelPlural: 'Wrong Id Type Objects', - description: 'Object with wrong id field type', - additionalFields: [ - { - universalIdentifier: - computeDeterministicTestUuid('wrongIdTypeObject-id'), - type: FieldMetadataType.TEXT, - name: 'id', - label: 'Id', - description: 'Id field with wrong type', - icon: 'IconKey', - }, - ], - }), - ), - }, - }, - { - title: 'when object miss default fields', - context: { - manifest: buildManifest( - buildObjectWithLabelField({ - nameSingular: 'wrongCreatedAtObject', - namePlural: 'wrongCreatedAtObjects', - labelSingular: 'Wrong CreatedAt Object', - labelPlural: 'Wrong CreatedAt Objects', - description: 'Object with wrong createdAt field type', - }), - ), - }, - }, - { - title: - 'when object has position field with wrong type (TEXT instead of POSITION)', - context: { - manifest: buildManifest( - buildObjectWithLabelField({ - nameSingular: 'wrongPositionObject', - namePlural: 'wrongPositionObjects', - labelSingular: 'Wrong Position Object', - labelPlural: 'Wrong Position Objects', - description: 'Object with wrong position field type', - additionalFields: [ - { - universalIdentifier: computeDeterministicTestUuid( - 'wrongPositionObject-position', - ), - type: FieldMetadataType.TEXT, - name: 'position', - label: 'Position', - description: 'Position field with wrong type', - icon: 'IconArrowsSort', - }, - ], - }), - ), - }, - }, - { - title: - 'when object has searchVector field with wrong type (TEXT instead of TS_VECTOR)', - context: { - manifest: buildManifest( - buildDefaultObjectWithModifiedSearchVector({ - nameSingular: 'wrongSearchVectorType', - namePlural: 'wrongSearchVectorTypes', - labelSingular: 'Wrong SearchVector Type', - labelPlural: 'Wrong SearchVector Types', - description: 'Object with wrong searchVector field type', - searchVectorOverrides: { - type: FieldMetadataType.TEXT, - }, - }), - ), - }, - }, - { - title: - 'when object has TS_VECTOR field with wrong name (not searchVector)', - context: { - manifest: buildManifest( - buildDefaultObjectWithModifiedSearchVector({ - nameSingular: 'wrongTsVectorName', - namePlural: 'wrongTsVectorNames', - labelSingular: 'Wrong TsVector Name', - labelPlural: 'Wrong TsVector Names', - description: 'Object with TS_VECTOR field named incorrectly', - searchVectorOverrides: { - name: 'wrongSearchVector', - }, - }), - ), - }, - }, - { - title: - 'when object has a system field with a custom (non-derived) universal identifier', - context: (() => { - const defaultObject = buildDefaultObjectManifest({ - applicationUniversalIdentifier: TEST_APP_ID, - universalIdentifier: computeDeterministicTestUuid( - 'customSystemFieldUidObject', - ), - nameSingular: 'customSystemFieldUidObject', - namePlural: 'customSystemFieldUidObjects', - labelSingular: 'Custom System Field Uid Object', - labelPlural: 'Custom System Field Uid Objects', - description: - 'Object with a createdAt system field carrying a custom universal identifier', - }); - - return { - manifest: buildManifest({ - objects: [ - { - ...defaultObject, - fields: defaultObject.fields.map((field) => - field.name === 'createdAt' - ? { - ...field, - universalIdentifier: - '899ac540-3a1f-42cf-9f99-8a55e79d0d9e', - } - : field, - ), - }, - ], - fields: [], - }), - }; - })(), - }, - { - title: - 'when label identifier is non-searchable type (searchVector has no expression)', - context: (() => { - const nonSearchableFieldId = computeDeterministicTestUuid( - 'noSearchVectorExpression-quantity', - ); - - return { - manifest: buildManifest({ - objects: [ - buildDefaultObjectManifest({ - applicationUniversalIdentifier: TEST_APP_ID, - universalIdentifier: computeDeterministicTestUuid( - 'noSearchVectorExpression', - ), - nameSingular: 'noSearchVectorExpression', - namePlural: 'noSearchVectorExpressions', - labelSingular: 'No SearchVector Expression', - labelPlural: 'No SearchVector Expressions', - description: - 'Object whose label identifier is non-searchable, so searchVector has no expression', - labelIdentifierFieldMetadataUniversalIdentifier: - nonSearchableFieldId, - additionalFields: [ - { - universalIdentifier: nonSearchableFieldId, - type: FieldMetadataType.NUMBER, - name: 'quantity', - label: 'Quantity', - }, - ], - }), - ], - fields: [], - }), - }; - })(), - }, - ]; - -describe('Sync application should fail due to object system fields integrity', () => { - beforeAll(async () => { - await setupApplicationForSync({ - applicationUniversalIdentifier: TEST_APP_ID, - name: 'Test System Fields App', - description: 'App for testing system field validation', - sourcePath: 'test-system-fields', - }); - }, 60000); - - afterAll(async () => { - await cleanupApplicationAndAppRegistration({ - applicationUniversalIdentifier: TEST_APP_ID, - }); - }); - - it.each( - eachTestingContextFilter(failingSyncApplicationSystemFieldsTestCases), - )( - '$title', - async ({ context }) => { - const { errors } = await syncApplication({ - manifest: context.manifest, - expectToFail: true, - }); - - expectOneNotInternalServerErrorSnapshot({ errors }); - }, - 60000, - ); - - it('should fail when trying to delete a system field after a successful sync', async () => { - const labelIdentifierFieldUniversalIdentifier = uuidv4(); - const testObject = buildDefaultObjectManifest({ - applicationUniversalIdentifier: TEST_APP_ID, - nameSingular: 'deleteSystemFieldObject', - namePlural: 'deleteSystemFieldObjects', - labelSingular: 'Delete System Field Object', - labelPlural: 'Delete System Field Objects', - description: 'Object for testing system field deletion', - labelIdentifierFieldMetadataUniversalIdentifier: - labelIdentifierFieldUniversalIdentifier, - additionalFields: [ - { - universalIdentifier: labelIdentifierFieldUniversalIdentifier, - type: FieldMetadataType.TEXT, - name: 'labelIdentifierField', - label: 'Label Identifier Field', - description: 'Label identifier field', - icon: 'IconTextCaption', - }, - ], - }); - - const validManifest = buildManifest({ - objects: [testObject], - fields: [], - }); - - await syncApplication({ - manifest: validManifest, - expectToFail: false, - }); - - const manifestWithDeletedIdField = buildManifest({ - objects: [ - { - ...testObject, - fields: testObject.fields.filter((field) => field.name !== 'id'), - }, - ], - fields: [], - }); - - const { errors } = await syncApplication({ - manifest: manifestWithDeletedIdField, - expectToFail: true, - }); - - expectOneNotInternalServerErrorSnapshot({ errors }); - }, 60000); - - it('should fail when trying to update a system field after a successful sync', async () => { - const labelIdentifierFieldUniversalIdentifier = uuidv4(); - const testObject = buildDefaultObjectManifest({ - applicationUniversalIdentifier: TEST_APP_ID, - nameSingular: 'updateSystemFieldObject', - namePlural: 'updateSystemFieldObjects', - labelSingular: 'Update System Field Object', - labelPlural: 'Update System Field Objects', - description: 'Object for testing system field update', - labelIdentifierFieldMetadataUniversalIdentifier: - labelIdentifierFieldUniversalIdentifier, - additionalFields: [ - { - universalIdentifier: labelIdentifierFieldUniversalIdentifier, - type: FieldMetadataType.TEXT, - name: 'labelIdentifierField', - label: 'Label Identifier Field', - description: 'Label identifier field', - icon: 'IconTextCaption', - }, - ], - }); - - const validManifest = buildManifest({ - objects: [testObject], - fields: [], - }); - - await syncApplication({ - manifest: validManifest, - expectToFail: false, - }); - - const manifestWithUpdatedIdField = buildManifest({ - objects: [ - { - ...testObject, - fields: testObject.fields.map((field) => - field.name === 'id' - ? { ...field, label: 'Modified Id Label' } - : field, - ), - }, - ], - fields: [], - }); - - const { errors } = await syncApplication({ - manifest: manifestWithUpdatedIdField, - expectToFail: true, - }); - - expectOneNotInternalServerErrorSnapshot({ errors }); - }, 60000); -}); diff --git a/packages/twenty-server/test/integration/metadata/suites/application/successful-sync-application-deterministic-field-universal-identifiers.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/application/successful-sync-application-deterministic-field-universal-identifiers.integration-spec.ts new file mode 100644 index 0000000000..b6306c9bcb --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/application/successful-sync-application-deterministic-field-universal-identifiers.integration-spec.ts @@ -0,0 +1,137 @@ +import { buildBaseManifest } from 'test/integration/metadata/suites/application/utils/build-base-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 { findManyFieldsMetadata } from 'test/integration/metadata/suites/field-metadata/utils/find-many-fields-metadata.util'; +import { findManyObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/find-many-object-metadata.util'; +import { + getFieldUniversalIdentifier, + 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 SYSTEM_FIELD_NAMES = [ + 'id', + 'createdAt', + 'updatedAt', + 'deletedAt', + 'createdBy', + 'updatedBy', + 'position', + 'searchVector', +]; + +const TEST_APP_UNIVERSAL_IDENTIFIER = uuidv4(); +const TEST_ROLE_UNIVERSAL_IDENTIFIER = uuidv4(); +const OBJECT_UNIVERSAL_IDENTIFIER = uuidv4(); +const NAME_FIELD_UNIVERSAL_IDENTIFIER = uuidv4(); + +const OBJECT_NAME_SINGULAR = 'rocketForManifestUniversalIdentifier'; + +const TEST_OBJECT: ObjectManifest = { + universalIdentifier: OBJECT_UNIVERSAL_IDENTIFIER, + labelIdentifierFieldMetadataUniversalIdentifier: + NAME_FIELD_UNIVERSAL_IDENTIFIER, + nameSingular: OBJECT_NAME_SINGULAR, + namePlural: `${OBJECT_NAME_SINGULAR}s`, + labelSingular: 'Rocket For Manifest Universal Identifier', + labelPlural: 'Rockets For Manifest Universal Identifier', + description: 'A rocket synced through the manifest funnel', + icon: 'IconRocket', + fields: [ + { + universalIdentifier: NAME_FIELD_UNIVERSAL_IDENTIFIER, + type: FieldMetadataType.TEXT, + name: 'name', + label: 'Name', + }, + ], +}; + +type FetchedField = { + id: string; + name: string; + universalIdentifier: string; +}; + +describe('Application manifest sync deterministic system field universal identifiers', () => { + let objectUniversalIdentifier: string; + let fetchedFields: FetchedField[]; + + beforeAll(async () => { + await setupApplicationForSync({ + applicationUniversalIdentifier: TEST_APP_UNIVERSAL_IDENTIFIER, + name: 'Test Application', + description: 'A test application', + sourcePath: 'test-sync-deterministic', + }); + + await syncApplication({ + expectToFail: false, + manifest: buildBaseManifest({ + appId: TEST_APP_UNIVERSAL_IDENTIFIER, + roleId: TEST_ROLE_UNIVERSAL_IDENTIFIER, + overrides: { objects: [TEST_OBJECT] }, + }), + }); + + const { objects } = await findManyObjectMetadata({ + expectToFail: false, + input: { filter: {}, paging: { first: 100 } }, + gqlFields: 'id nameSingular universalIdentifier', + }); + + const syncedObject = objects.find( + (object) => object.universalIdentifier === OBJECT_UNIVERSAL_IDENTIFIER, + ); + + if (!isDefined(syncedObject)) { + throw new Error( + 'Could not resolve the object synced through the manifest funnel', + ); + } + + objectUniversalIdentifier = syncedObject.universalIdentifier; + + const { fields } = await findManyFieldsMetadata({ + expectToFail: false, + input: { + filter: { objectMetadataId: { eq: syncedObject.id } }, + paging: { first: 100 }, + }, + gqlFields: ` + id + name + universalIdentifier + `, + }); + + fetchedFields = fields.map((edge: { node: FetchedField }) => edge.node); + }, 60000); + + afterAll(async () => { + await cleanupApplicationAndAppRegistration({ + applicationUniversalIdentifier: TEST_APP_UNIVERSAL_IDENTIFIER, + }); + }); + + it.each(SYSTEM_FIELD_NAMES)( + 'should derive the %s system field universal identifier deterministically through the manifest sync funnel', + (systemFieldName) => { + const systemField = fetchedFields.find( + (field) => field.name === systemFieldName, + ); + + expect(systemField).toBeDefined(); + expect(systemField?.universalIdentifier).toBe( + getFieldUniversalIdentifier({ + applicationUniversalIdentifier: TEST_APP_UNIVERSAL_IDENTIFIER, + objectUniversalIdentifier, + name: systemFieldName, + }), + ); + }, + ); +}); diff --git a/packages/twenty-server/test/integration/metadata/suites/application/successful-sync-application-search-field-metadata-relabel.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/application/successful-sync-application-search-field-metadata-relabel.integration-spec.ts new file mode 100644 index 0000000000..2556aeca3b --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/application/successful-sync-application-search-field-metadata-relabel.integration-spec.ts @@ -0,0 +1,170 @@ +import { createManyOperation } from 'test/integration/graphql/utils/create-many-operation.util'; +import { search } from 'test/integration/graphql/utils/search.util'; +import { buildBaseManifest } from 'test/integration/metadata/suites/application/utils/build-base-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 { + getFieldUniversalIdentifier, + type ObjectManifest, +} from 'twenty-shared/application'; +import { FieldMetadataType } from 'twenty-shared/types'; +import { v4 as uuidv4 } from 'uuid'; + +const TEST_APP_UNIVERSAL_IDENTIFIER = uuidv4(); +const TEST_ROLE_UNIVERSAL_IDENTIFIER = uuidv4(); +const OBJECT_UNIVERSAL_IDENTIFIER = uuidv4(); + +const OBJECT_NAME_SINGULAR = 'relabelableRocket'; +const OBJECT_NAME_PLURAL = 'relabelableRockets'; + +const RECORD_NAME_VALUE = 'AlphaManifestRelabelNameTerm'; +const RECORD_TOTO_VALUE = 'BravoManifestRelabelTotoTerm'; + +const NAME_FIELD_UNIVERSAL_IDENTIFIER = getFieldUniversalIdentifier({ + applicationUniversalIdentifier: TEST_APP_UNIVERSAL_IDENTIFIER, + objectUniversalIdentifier: OBJECT_UNIVERSAL_IDENTIFIER, + name: 'name', +}); + +const TOTO_FIELD_UNIVERSAL_IDENTIFIER = getFieldUniversalIdentifier({ + applicationUniversalIdentifier: TEST_APP_UNIVERSAL_IDENTIFIER, + objectUniversalIdentifier: OBJECT_UNIVERSAL_IDENTIFIER, + name: 'toto', +}); + +const NAME_FIELD_MANIFEST = { + universalIdentifier: NAME_FIELD_UNIVERSAL_IDENTIFIER, + type: FieldMetadataType.TEXT, + name: 'name', + label: 'Name', +} as const; + +const TOTO_FIELD_MANIFEST = { + universalIdentifier: TOTO_FIELD_UNIVERSAL_IDENTIFIER, + type: FieldMetadataType.TEXT, + name: 'toto', + label: 'Toto', +} as const; + +const buildObjectManifest = ({ + labelIdentifierFieldMetadataUniversalIdentifier, + fields, +}: { + labelIdentifierFieldMetadataUniversalIdentifier: string; + fields: ObjectManifest['fields']; +}): ObjectManifest => ({ + universalIdentifier: OBJECT_UNIVERSAL_IDENTIFIER, + labelIdentifierFieldMetadataUniversalIdentifier, + nameSingular: OBJECT_NAME_SINGULAR, + namePlural: OBJECT_NAME_PLURAL, + labelSingular: 'Relabelable Rocket', + labelPlural: 'Relabelable Rockets', + description: 'A rocket whose label identifier is relabeled across syncs', + icon: 'IconRocket', + isSearchable: true, + fields, +}); + +const syncObjectManifest = async (objectManifest: ObjectManifest) => { + await syncApplication({ + expectToFail: false, + manifest: buildBaseManifest({ + appId: TEST_APP_UNIVERSAL_IDENTIFIER, + roleId: TEST_ROLE_UNIVERSAL_IDENTIFIER, + overrides: { objects: [objectManifest] }, + }), + }); +}; + +const searchRecordIds = async (searchInput: string): Promise => { + const searchResult = await search({ + searchInput, + includedObjectNameSingulars: [OBJECT_NAME_SINGULAR], + limit: 10, + expectToFail: false, + }); + + return searchResult.data.search.edges.map((edge) => edge.node.recordId); +}; + +describe('Application manifest sync - search field metadata on label identifier relabel', () => { + let recordId: string; + + beforeAll(async () => { + await setupApplicationForSync({ + applicationUniversalIdentifier: TEST_APP_UNIVERSAL_IDENTIFIER, + name: 'Test Application', + description: 'A test application', + sourcePath: 'test-sync-search-relabel', + }); + + // First sync: object with `name` and `toto` text fields, `name` as label + // identifier. `toto` exists from the start but is not the label identifier, + // so only `name` is indexed for search initially. + // NOTE: `toto` is created here rather than in the relabel sync because a + // single sync cannot both create a field and relabel the object onto it + // yet (objectMetadata.update is ordered before fieldMetadata.create in the + // migration runner). Tracked for a follow-up core fix: + // https://github.com/twentyhq/core-team-issues/issues/2655 + await syncObjectManifest( + buildObjectManifest({ + labelIdentifierFieldMetadataUniversalIdentifier: + NAME_FIELD_UNIVERSAL_IDENTIFIER, + fields: [NAME_FIELD_MANIFEST, TOTO_FIELD_MANIFEST], + }), + ); + + const { data } = await createManyOperation({ + objectMetadataSingularName: OBJECT_NAME_SINGULAR, + objectMetadataPluralName: OBJECT_NAME_PLURAL, + gqlFields: 'id name toto', + data: [{ name: RECORD_NAME_VALUE, toto: RECORD_TOTO_VALUE }], + expectToFail: false, + }); + + recordId = data.createdRecords[0].id; + }, 120000); + + afterAll(async () => { + await cleanupApplicationAndAppRegistration({ + applicationUniversalIdentifier: TEST_APP_UNIVERSAL_IDENTIFIER, + }); + }); + + it('should make records searchable through the name label identifier only', async () => { + expect(await searchRecordIds(RECORD_NAME_VALUE)).toEqual([recordId]); + // `toto` is not the label identifier yet, so it is not part of the search surface. + expect(await searchRecordIds(RECORD_TOTO_VALUE)).toEqual([]); + }, 120000); + + it('should make records searchable through both name and toto after relabeling onto toto', async () => { + // Second sync: relabel the object onto the pre-existing `toto` field. + await syncObjectManifest( + buildObjectManifest({ + labelIdentifierFieldMetadataUniversalIdentifier: + TOTO_FIELD_UNIVERSAL_IDENTIFIER, + fields: [NAME_FIELD_MANIFEST, TOTO_FIELD_MANIFEST], + }), + ); + + // Relabeling is additive: the previous `name` surface is preserved. + expect(await searchRecordIds(RECORD_NAME_VALUE)).toEqual([recordId]); + expect(await searchRecordIds(RECORD_TOTO_VALUE)).toEqual([recordId]); + }, 120000); + + it('should only remain searchable through name after toto is removed', async () => { + // Third sync: relabel back onto `name` and remove the `toto` field. Removing + // the field must drop its searchFieldMetadata row so it leaves the search surface. + await syncObjectManifest( + buildObjectManifest({ + labelIdentifierFieldMetadataUniversalIdentifier: + NAME_FIELD_UNIVERSAL_IDENTIFIER, + fields: [NAME_FIELD_MANIFEST], + }), + ); + + expect(await searchRecordIds(RECORD_NAME_VALUE)).toEqual([recordId]); + expect(await searchRecordIds(RECORD_TOTO_VALUE)).toEqual([]); + }, 120000); +}); diff --git a/packages/twenty-server/test/integration/metadata/suites/application/successful-sync-application-search-field-metadata.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/application/successful-sync-application-search-field-metadata.integration-spec.ts new file mode 100644 index 0000000000..1369d9004b --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/application/successful-sync-application-search-field-metadata.integration-spec.ts @@ -0,0 +1,131 @@ +import { createManyOperation } from 'test/integration/graphql/utils/create-many-operation.util'; +import { search } from 'test/integration/graphql/utils/search.util'; +import { buildBaseManifest } from 'test/integration/metadata/suites/application/utils/build-base-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 { + getFieldUniversalIdentifier, + type ObjectManifest, +} from 'twenty-shared/application'; +import { FieldMetadataType } from 'twenty-shared/types'; +import { v4 as uuidv4 } from 'uuid'; + +const TEST_APP_UNIVERSAL_IDENTIFIER = uuidv4(); +const TEST_ROLE_UNIVERSAL_IDENTIFIER = uuidv4(); +const OBJECT_UNIVERSAL_IDENTIFIER = uuidv4(); + +const OBJECT_NAME_SINGULAR = 'searchableRocket'; +const OBJECT_NAME_PLURAL = 'searchableRockets'; + +const RECORD_NAME_VALUE = 'FalconHeavyManifestSearchTerm'; +const OTHER_RECORD_NAME_VALUE = 'AtlasFiveManifestSearchTerm'; + +const NAME_FIELD_UNIVERSAL_IDENTIFIER = getFieldUniversalIdentifier({ + applicationUniversalIdentifier: TEST_APP_UNIVERSAL_IDENTIFIER, + objectUniversalIdentifier: OBJECT_UNIVERSAL_IDENTIFIER, + name: 'name', +}); + +const TEST_OBJECT: ObjectManifest = { + universalIdentifier: OBJECT_UNIVERSAL_IDENTIFIER, + labelIdentifierFieldMetadataUniversalIdentifier: + NAME_FIELD_UNIVERSAL_IDENTIFIER, + nameSingular: OBJECT_NAME_SINGULAR, + namePlural: OBJECT_NAME_PLURAL, + labelSingular: 'Searchable Rocket', + labelPlural: 'Searchable Rockets', + description: 'A rocket synced through the manifest funnel', + icon: 'IconRocket', + isSearchable: true, + fields: [ + { + universalIdentifier: NAME_FIELD_UNIVERSAL_IDENTIFIER, + type: FieldMetadataType.TEXT, + name: 'name', + label: 'Name', + }, + ], +}; + +describe('Application manifest sync search field metadata population', () => { + let matchingRecordId: string; + + beforeAll(async () => { + await setupApplicationForSync({ + applicationUniversalIdentifier: TEST_APP_UNIVERSAL_IDENTIFIER, + name: 'Test Application', + description: 'A test application', + sourcePath: 'test-sync-search', + }); + + await syncApplication({ + expectToFail: false, + manifest: buildBaseManifest({ + appId: TEST_APP_UNIVERSAL_IDENTIFIER, + roleId: TEST_ROLE_UNIVERSAL_IDENTIFIER, + overrides: { objects: [TEST_OBJECT] }, + }), + }); + + const { data } = await createManyOperation({ + objectMetadataSingularName: OBJECT_NAME_SINGULAR, + objectMetadataPluralName: OBJECT_NAME_PLURAL, + gqlFields: 'id name', + data: [ + { name: RECORD_NAME_VALUE }, + { name: OTHER_RECORD_NAME_VALUE }, + ], + expectToFail: false, + }); + + const matchingRecord = data.createdRecords.find( + (record) => record.name === RECORD_NAME_VALUE, + ); + + if (matchingRecord === undefined) { + throw new Error('Could not create the record to search for'); + } + + matchingRecordId = matchingRecord.id; + }, 60000); + + afterAll(async () => { + await cleanupApplicationAndAppRegistration({ + applicationUniversalIdentifier: TEST_APP_UNIVERSAL_IDENTIFIER, + }); + }); + + it('should make the synced object searchable through its server-synthesized search field metadata', async () => { + const searchResult = await search({ + searchInput: RECORD_NAME_VALUE, + includedObjectNameSingulars: [OBJECT_NAME_SINGULAR], + limit: 10, + expectToFail: false, + }); + + expect(searchResult.data.search.edges).toHaveLength(1); + + const searchResultNode = searchResult.data.search.edges[0].node; + + expect(searchResultNode.recordId).toBe(matchingRecordId); + expect(searchResultNode.objectNameSingular).toBe(OBJECT_NAME_SINGULAR); + expect(searchResultNode.label).toBe(RECORD_NAME_VALUE); + }, 60000); + + it('should not return records whose label identifier does not match the search term', async () => { + const searchResult = await search({ + searchInput: RECORD_NAME_VALUE, + includedObjectNameSingulars: [OBJECT_NAME_SINGULAR], + limit: 10, + expectToFail: false, + }); + + const returnedRecordIds = searchResult.data.search.edges.map( + (edge) => edge.node.recordId, + ); + + expect(returnedRecordIds).not.toContain(OTHER_RECORD_NAME_VALUE); + expect(returnedRecordIds).toEqual([matchingRecordId]); + }, 60000); +}); diff --git a/packages/twenty-server/test/integration/metadata/suites/object-metadata/__snapshots__/failing-create-one-object-metadata-v2.integration-spec.ts.snap b/packages/twenty-server/test/integration/metadata/suites/object-metadata/__snapshots__/failing-create-one-object-metadata-v2.integration-spec.ts.snap index e722262a35..2138c3fb80 100644 --- a/packages/twenty-server/test/integration/metadata/suites/object-metadata/__snapshots__/failing-create-one-object-metadata-v2.integration-spec.ts.snap +++ b/packages/twenty-server/test/integration/metadata/suites/object-metadata/__snapshots__/failing-create-one-object-metadata-v2.integration-spec.ts.snap @@ -297,27 +297,6 @@ exports[`Object metadata creation should fail v2 when labelPlural contains only }, ], "index": [ - { - "errors": [ - { - "code": "INDEX_OBJECT_NOT_FOUND", - "message": "Could not find index related object metadata", - "userFriendlyMessage": "Index related object not found", - }, - { - "code": "INDEX_FIELD_NOT_FOUND", - "message": "Could not find index field related field metadata", - "userFriendlyMessage": "Field referenced in index does not exist", - }, - ], - "flatEntityMinimalInformation": { - "name": "IDX_ffebb98ec981552dbecc71223c8", - "universalIdentifier": Any, - }, - "metadataName": "index", - "status": "fail", - "type": "create", - }, { "errors": [ { @@ -382,6 +361,27 @@ exports[`Object metadata creation should fail v2 when labelPlural contains only "status": "fail", "type": "create", }, + { + "errors": [ + { + "code": "INDEX_OBJECT_NOT_FOUND", + "message": "Could not find index related object metadata", + "userFriendlyMessage": "Index related object not found", + }, + { + "code": "INDEX_FIELD_NOT_FOUND", + "message": "Could not find index field related field metadata", + "userFriendlyMessage": "Field referenced in index does not exist", + }, + ], + "flatEntityMinimalInformation": { + "name": "IDX_ffebb98ec981552dbecc71223c8", + "universalIdentifier": Any, + }, + "metadataName": "index", + "status": "fail", + "type": "create", + }, ], "objectMetadata": [ { @@ -978,27 +978,6 @@ exports[`Object metadata creation should fail v2 when labelPlural exceeds maximu }, ], "index": [ - { - "errors": [ - { - "code": "INDEX_OBJECT_NOT_FOUND", - "message": "Could not find index related object metadata", - "userFriendlyMessage": "Index related object not found", - }, - { - "code": "INDEX_FIELD_NOT_FOUND", - "message": "Could not find index field related field metadata", - "userFriendlyMessage": "Field referenced in index does not exist", - }, - ], - "flatEntityMinimalInformation": { - "name": "IDX_ffebb98ec981552dbecc71223c8", - "universalIdentifier": Any, - }, - "metadataName": "index", - "status": "fail", - "type": "create", - }, { "errors": [ { @@ -1063,6 +1042,27 @@ exports[`Object metadata creation should fail v2 when labelPlural exceeds maximu "status": "fail", "type": "create", }, + { + "errors": [ + { + "code": "INDEX_OBJECT_NOT_FOUND", + "message": "Could not find index related object metadata", + "userFriendlyMessage": "Index related object not found", + }, + { + "code": "INDEX_FIELD_NOT_FOUND", + "message": "Could not find index field related field metadata", + "userFriendlyMessage": "Field referenced in index does not exist", + }, + ], + "flatEntityMinimalInformation": { + "name": "IDX_ffebb98ec981552dbecc71223c8", + "universalIdentifier": Any, + }, + "metadataName": "index", + "status": "fail", + "type": "create", + }, ], "objectMetadata": [ { @@ -1670,27 +1670,6 @@ exports[`Object metadata creation should fail v2 when labelSingular contains onl }, ], "index": [ - { - "errors": [ - { - "code": "INDEX_OBJECT_NOT_FOUND", - "message": "Could not find index related object metadata", - "userFriendlyMessage": "Index related object not found", - }, - { - "code": "INDEX_FIELD_NOT_FOUND", - "message": "Could not find index field related field metadata", - "userFriendlyMessage": "Field referenced in index does not exist", - }, - ], - "flatEntityMinimalInformation": { - "name": "IDX_ffebb98ec981552dbecc71223c8", - "universalIdentifier": Any, - }, - "metadataName": "index", - "status": "fail", - "type": "create", - }, { "errors": [ { @@ -1755,6 +1734,27 @@ exports[`Object metadata creation should fail v2 when labelSingular contains onl "status": "fail", "type": "create", }, + { + "errors": [ + { + "code": "INDEX_OBJECT_NOT_FOUND", + "message": "Could not find index related object metadata", + "userFriendlyMessage": "Index related object not found", + }, + { + "code": "INDEX_FIELD_NOT_FOUND", + "message": "Could not find index field related field metadata", + "userFriendlyMessage": "Field referenced in index does not exist", + }, + ], + "flatEntityMinimalInformation": { + "name": "IDX_ffebb98ec981552dbecc71223c8", + "universalIdentifier": Any, + }, + "metadataName": "index", + "status": "fail", + "type": "create", + }, ], "objectMetadata": [ { @@ -2351,27 +2351,6 @@ exports[`Object metadata creation should fail v2 when labelSingular exceeds maxi }, ], "index": [ - { - "errors": [ - { - "code": "INDEX_OBJECT_NOT_FOUND", - "message": "Could not find index related object metadata", - "userFriendlyMessage": "Index related object not found", - }, - { - "code": "INDEX_FIELD_NOT_FOUND", - "message": "Could not find index field related field metadata", - "userFriendlyMessage": "Field referenced in index does not exist", - }, - ], - "flatEntityMinimalInformation": { - "name": "IDX_ffebb98ec981552dbecc71223c8", - "universalIdentifier": Any, - }, - "metadataName": "index", - "status": "fail", - "type": "create", - }, { "errors": [ { @@ -2436,6 +2415,27 @@ exports[`Object metadata creation should fail v2 when labelSingular exceeds maxi "status": "fail", "type": "create", }, + { + "errors": [ + { + "code": "INDEX_OBJECT_NOT_FOUND", + "message": "Could not find index related object metadata", + "userFriendlyMessage": "Index related object not found", + }, + { + "code": "INDEX_FIELD_NOT_FOUND", + "message": "Could not find index field related field metadata", + "userFriendlyMessage": "Field referenced in index does not exist", + }, + ], + "flatEntityMinimalInformation": { + "name": "IDX_ffebb98ec981552dbecc71223c8", + "universalIdentifier": Any, + }, + "metadataName": "index", + "status": "fail", + "type": "create", + }, ], "objectMetadata": [ { @@ -3067,27 +3067,6 @@ exports[`Object metadata creation should fail v2 when name exceeds maximum lengt }, ], "index": [ - { - "errors": [ - { - "code": "INDEX_OBJECT_NOT_FOUND", - "message": "Could not find index related object metadata", - "userFriendlyMessage": "Index related object not found", - }, - { - "code": "INDEX_FIELD_NOT_FOUND", - "message": "Could not find index field related field metadata", - "userFriendlyMessage": "Field referenced in index does not exist", - }, - ], - "flatEntityMinimalInformation": { - "name": "IDX_da4ca4dc0c441477f29e4a384b0", - "universalIdentifier": Any, - }, - "metadataName": "index", - "status": "fail", - "type": "create", - }, { "errors": [ { @@ -3152,6 +3131,27 @@ exports[`Object metadata creation should fail v2 when name exceeds maximum lengt "status": "fail", "type": "create", }, + { + "errors": [ + { + "code": "INDEX_OBJECT_NOT_FOUND", + "message": "Could not find index related object metadata", + "userFriendlyMessage": "Index related object not found", + }, + { + "code": "INDEX_FIELD_NOT_FOUND", + "message": "Could not find index field related field metadata", + "userFriendlyMessage": "Field referenced in index does not exist", + }, + ], + "flatEntityMinimalInformation": { + "name": "IDX_da4ca4dc0c441477f29e4a384b0", + "universalIdentifier": Any, + }, + "metadataName": "index", + "status": "fail", + "type": "create", + }, ], "objectMetadata": [ { @@ -3748,27 +3748,6 @@ exports[`Object metadata creation should fail v2 when namePlural has invalid cha }, ], "index": [ - { - "errors": [ - { - "code": "INDEX_OBJECT_NOT_FOUND", - "message": "Could not find index related object metadata", - "userFriendlyMessage": "Index related object not found", - }, - { - "code": "INDEX_FIELD_NOT_FOUND", - "message": "Could not find index field related field metadata", - "userFriendlyMessage": "Field referenced in index does not exist", - }, - ], - "flatEntityMinimalInformation": { - "name": "IDX_ffebb98ec981552dbecc71223c8", - "universalIdentifier": Any, - }, - "metadataName": "index", - "status": "fail", - "type": "create", - }, { "errors": [ { @@ -3833,6 +3812,27 @@ exports[`Object metadata creation should fail v2 when namePlural has invalid cha "status": "fail", "type": "create", }, + { + "errors": [ + { + "code": "INDEX_OBJECT_NOT_FOUND", + "message": "Could not find index related object metadata", + "userFriendlyMessage": "Index related object not found", + }, + { + "code": "INDEX_FIELD_NOT_FOUND", + "message": "Could not find index field related field metadata", + "userFriendlyMessage": "Field referenced in index does not exist", + }, + ], + "flatEntityMinimalInformation": { + "name": "IDX_ffebb98ec981552dbecc71223c8", + "universalIdentifier": Any, + }, + "metadataName": "index", + "status": "fail", + "type": "create", + }, ], "objectMetadata": [ { @@ -4429,27 +4429,6 @@ exports[`Object metadata creation should fail v2 when namePlural is a reserved k }, ], "index": [ - { - "errors": [ - { - "code": "INDEX_OBJECT_NOT_FOUND", - "message": "Could not find index related object metadata", - "userFriendlyMessage": "Index related object not found", - }, - { - "code": "INDEX_FIELD_NOT_FOUND", - "message": "Could not find index field related field metadata", - "userFriendlyMessage": "Field referenced in index does not exist", - }, - ], - "flatEntityMinimalInformation": { - "name": "IDX_ffebb98ec981552dbecc71223c8", - "universalIdentifier": Any, - }, - "metadataName": "index", - "status": "fail", - "type": "create", - }, { "errors": [ { @@ -4514,6 +4493,27 @@ exports[`Object metadata creation should fail v2 when namePlural is a reserved k "status": "fail", "type": "create", }, + { + "errors": [ + { + "code": "INDEX_OBJECT_NOT_FOUND", + "message": "Could not find index related object metadata", + "userFriendlyMessage": "Index related object not found", + }, + { + "code": "INDEX_FIELD_NOT_FOUND", + "message": "Could not find index field related field metadata", + "userFriendlyMessage": "Field referenced in index does not exist", + }, + ], + "flatEntityMinimalInformation": { + "name": "IDX_ffebb98ec981552dbecc71223c8", + "universalIdentifier": Any, + }, + "metadataName": "index", + "status": "fail", + "type": "create", + }, ], "objectMetadata": [ { @@ -5121,27 +5121,6 @@ exports[`Object metadata creation should fail v2 when namePlural is not camelCas }, ], "index": [ - { - "errors": [ - { - "code": "INDEX_OBJECT_NOT_FOUND", - "message": "Could not find index related object metadata", - "userFriendlyMessage": "Index related object not found", - }, - { - "code": "INDEX_FIELD_NOT_FOUND", - "message": "Could not find index field related field metadata", - "userFriendlyMessage": "Field referenced in index does not exist", - }, - ], - "flatEntityMinimalInformation": { - "name": "IDX_ffebb98ec981552dbecc71223c8", - "universalIdentifier": Any, - }, - "metadataName": "index", - "status": "fail", - "type": "create", - }, { "errors": [ { @@ -5206,6 +5185,27 @@ exports[`Object metadata creation should fail v2 when namePlural is not camelCas "status": "fail", "type": "create", }, + { + "errors": [ + { + "code": "INDEX_OBJECT_NOT_FOUND", + "message": "Could not find index related object metadata", + "userFriendlyMessage": "Index related object not found", + }, + { + "code": "INDEX_FIELD_NOT_FOUND", + "message": "Could not find index field related field metadata", + "userFriendlyMessage": "Field referenced in index does not exist", + }, + ], + "flatEntityMinimalInformation": { + "name": "IDX_ffebb98ec981552dbecc71223c8", + "universalIdentifier": Any, + }, + "metadataName": "index", + "status": "fail", + "type": "create", + }, ], "objectMetadata": [ { @@ -5826,27 +5826,6 @@ exports[`Object metadata creation should fail v2 when nameSingular contains only }, ], "index": [ - { - "errors": [ - { - "code": "INDEX_OBJECT_NOT_FOUND", - "message": "Could not find index related object metadata", - "userFriendlyMessage": "Index related object not found", - }, - { - "code": "INDEX_FIELD_NOT_FOUND", - "message": "Could not find index field related field metadata", - "userFriendlyMessage": "Field referenced in index does not exist", - }, - ], - "flatEntityMinimalInformation": { - "name": "IDX_2e70d840ef39341865172574229", - "universalIdentifier": Any, - }, - "metadataName": "index", - "status": "fail", - "type": "create", - }, { "errors": [ { @@ -5911,6 +5890,27 @@ exports[`Object metadata creation should fail v2 when nameSingular contains only "status": "fail", "type": "create", }, + { + "errors": [ + { + "code": "INDEX_OBJECT_NOT_FOUND", + "message": "Could not find index related object metadata", + "userFriendlyMessage": "Index related object not found", + }, + { + "code": "INDEX_FIELD_NOT_FOUND", + "message": "Could not find index field related field metadata", + "userFriendlyMessage": "Field referenced in index does not exist", + }, + ], + "flatEntityMinimalInformation": { + "name": "IDX_2e70d840ef39341865172574229", + "universalIdentifier": Any, + }, + "metadataName": "index", + "status": "fail", + "type": "create", + }, ], "objectMetadata": [ { @@ -6507,27 +6507,6 @@ exports[`Object metadata creation should fail v2 when nameSingular contains only }, ], "index": [ - { - "errors": [ - { - "code": "INDEX_OBJECT_NOT_FOUND", - "message": "Could not find index related object metadata", - "userFriendlyMessage": "Index related object not found", - }, - { - "code": "INDEX_FIELD_NOT_FOUND", - "message": "Could not find index field related field metadata", - "userFriendlyMessage": "Field referenced in index does not exist", - }, - ], - "flatEntityMinimalInformation": { - "name": "IDX_e0b8367f7f89bf39915991232b6", - "universalIdentifier": Any, - }, - "metadataName": "index", - "status": "fail", - "type": "create", - }, { "errors": [ { @@ -6592,6 +6571,27 @@ exports[`Object metadata creation should fail v2 when nameSingular contains only "status": "fail", "type": "create", }, + { + "errors": [ + { + "code": "INDEX_OBJECT_NOT_FOUND", + "message": "Could not find index related object metadata", + "userFriendlyMessage": "Index related object not found", + }, + { + "code": "INDEX_FIELD_NOT_FOUND", + "message": "Could not find index field related field metadata", + "userFriendlyMessage": "Field referenced in index does not exist", + }, + ], + "flatEntityMinimalInformation": { + "name": "IDX_e0b8367f7f89bf39915991232b6", + "universalIdentifier": Any, + }, + "metadataName": "index", + "status": "fail", + "type": "create", + }, ], "objectMetadata": [ { @@ -7218,27 +7218,6 @@ exports[`Object metadata creation should fail v2 when nameSingular has invalid c }, ], "index": [ - { - "errors": [ - { - "code": "INDEX_OBJECT_NOT_FOUND", - "message": "Could not find index related object metadata", - "userFriendlyMessage": "Index related object not found", - }, - { - "code": "INDEX_FIELD_NOT_FOUND", - "message": "Could not find index field related field metadata", - "userFriendlyMessage": "Field referenced in index does not exist", - }, - ], - "flatEntityMinimalInformation": { - "name": "IDX_e895f7d7a9fc30bad070509afab", - "universalIdentifier": Any, - }, - "metadataName": "index", - "status": "fail", - "type": "create", - }, { "errors": [ { @@ -7303,6 +7282,27 @@ exports[`Object metadata creation should fail v2 when nameSingular has invalid c "status": "fail", "type": "create", }, + { + "errors": [ + { + "code": "INDEX_OBJECT_NOT_FOUND", + "message": "Could not find index related object metadata", + "userFriendlyMessage": "Index related object not found", + }, + { + "code": "INDEX_FIELD_NOT_FOUND", + "message": "Could not find index field related field metadata", + "userFriendlyMessage": "Field referenced in index does not exist", + }, + ], + "flatEntityMinimalInformation": { + "name": "IDX_e895f7d7a9fc30bad070509afab", + "universalIdentifier": Any, + }, + "metadataName": "index", + "status": "fail", + "type": "create", + }, ], "objectMetadata": [ { @@ -7899,27 +7899,6 @@ exports[`Object metadata creation should fail v2 when nameSingular is a reserved }, ], "index": [ - { - "errors": [ - { - "code": "INDEX_OBJECT_NOT_FOUND", - "message": "Could not find index related object metadata", - "userFriendlyMessage": "Index related object not found", - }, - { - "code": "INDEX_FIELD_NOT_FOUND", - "message": "Could not find index field related field metadata", - "userFriendlyMessage": "Field referenced in index does not exist", - }, - ], - "flatEntityMinimalInformation": { - "name": "IDX_00aaf7dba3ae2d70218bdebd383", - "universalIdentifier": Any, - }, - "metadataName": "index", - "status": "fail", - "type": "create", - }, { "errors": [ { @@ -7984,6 +7963,27 @@ exports[`Object metadata creation should fail v2 when nameSingular is a reserved "status": "fail", "type": "create", }, + { + "errors": [ + { + "code": "INDEX_OBJECT_NOT_FOUND", + "message": "Could not find index related object metadata", + "userFriendlyMessage": "Index related object not found", + }, + { + "code": "INDEX_FIELD_NOT_FOUND", + "message": "Could not find index field related field metadata", + "userFriendlyMessage": "Field referenced in index does not exist", + }, + ], + "flatEntityMinimalInformation": { + "name": "IDX_00aaf7dba3ae2d70218bdebd383", + "universalIdentifier": Any, + }, + "metadataName": "index", + "status": "fail", + "type": "create", + }, ], "objectMetadata": [ { @@ -8615,27 +8615,6 @@ exports[`Object metadata creation should fail v2 when nameSingular is not camelC }, ], "index": [ - { - "errors": [ - { - "code": "INDEX_OBJECT_NOT_FOUND", - "message": "Could not find index related object metadata", - "userFriendlyMessage": "Index related object not found", - }, - { - "code": "INDEX_FIELD_NOT_FOUND", - "message": "Could not find index field related field metadata", - "userFriendlyMessage": "Field referenced in index does not exist", - }, - ], - "flatEntityMinimalInformation": { - "name": "IDX_17a51d4440e492a8ec4d594c445", - "universalIdentifier": Any, - }, - "metadataName": "index", - "status": "fail", - "type": "create", - }, { "errors": [ { @@ -8700,6 +8679,27 @@ exports[`Object metadata creation should fail v2 when nameSingular is not camelC "status": "fail", "type": "create", }, + { + "errors": [ + { + "code": "INDEX_OBJECT_NOT_FOUND", + "message": "Could not find index related object metadata", + "userFriendlyMessage": "Index related object not found", + }, + { + "code": "INDEX_FIELD_NOT_FOUND", + "message": "Could not find index field related field metadata", + "userFriendlyMessage": "Field referenced in index does not exist", + }, + ], + "flatEntityMinimalInformation": { + "name": "IDX_17a51d4440e492a8ec4d594c445", + "universalIdentifier": Any, + }, + "metadataName": "index", + "status": "fail", + "type": "create", + }, ], "objectMetadata": [ { @@ -9296,27 +9296,6 @@ exports[`Object metadata creation should fail v2 when names are identical 1`] = }, ], "index": [ - { - "errors": [ - { - "code": "INDEX_OBJECT_NOT_FOUND", - "message": "Could not find index related object metadata", - "userFriendlyMessage": "Index related object not found", - }, - { - "code": "INDEX_FIELD_NOT_FOUND", - "message": "Could not find index field related field metadata", - "userFriendlyMessage": "Field referenced in index does not exist", - }, - ], - "flatEntityMinimalInformation": { - "name": "IDX_acbec9630e5330ad7c000993538", - "universalIdentifier": Any, - }, - "metadataName": "index", - "status": "fail", - "type": "create", - }, { "errors": [ { @@ -9381,6 +9360,27 @@ exports[`Object metadata creation should fail v2 when names are identical 1`] = "status": "fail", "type": "create", }, + { + "errors": [ + { + "code": "INDEX_OBJECT_NOT_FOUND", + "message": "Could not find index related object metadata", + "userFriendlyMessage": "Index related object not found", + }, + { + "code": "INDEX_FIELD_NOT_FOUND", + "message": "Could not find index field related field metadata", + "userFriendlyMessage": "Field referenced in index does not exist", + }, + ], + "flatEntityMinimalInformation": { + "name": "IDX_acbec9630e5330ad7c000993538", + "universalIdentifier": Any, + }, + "metadataName": "index", + "status": "fail", + "type": "create", + }, ], "objectMetadata": [ { @@ -9977,27 +9977,6 @@ exports[`Object metadata creation should fail v2 when names with whitespaces res }, ], "index": [ - { - "errors": [ - { - "code": "INDEX_OBJECT_NOT_FOUND", - "message": "Could not find index related object metadata", - "userFriendlyMessage": "Index related object not found", - }, - { - "code": "INDEX_FIELD_NOT_FOUND", - "message": "Could not find index field related field metadata", - "userFriendlyMessage": "Field referenced in index does not exist", - }, - ], - "flatEntityMinimalInformation": { - "name": "IDX_acbec9630e5330ad7c000993538", - "universalIdentifier": Any, - }, - "metadataName": "index", - "status": "fail", - "type": "create", - }, { "errors": [ { @@ -10062,6 +10041,27 @@ exports[`Object metadata creation should fail v2 when names with whitespaces res "status": "fail", "type": "create", }, + { + "errors": [ + { + "code": "INDEX_OBJECT_NOT_FOUND", + "message": "Could not find index related object metadata", + "userFriendlyMessage": "Index related object not found", + }, + { + "code": "INDEX_FIELD_NOT_FOUND", + "message": "Could not find index field related field metadata", + "userFriendlyMessage": "Field referenced in index does not exist", + }, + ], + "flatEntityMinimalInformation": { + "name": "IDX_acbec9630e5330ad7c000993538", + "universalIdentifier": Any, + }, + "metadataName": "index", + "status": "fail", + "type": "create", + }, ], "objectMetadata": [ {