Deterministic system field universal identifier (#22565)

# Introduction

Close twentyhq/core-team-issues#2641

Auto-provisioned field metadata used to get its `universalIdentifier`
from three unrelated sources: random `v4()` on the server when creating
custom objects, hardcoded values in `STANDARD_OBJECTS`, and an ad-hoc
`v5` derivation in the SDK manifest build. This PR unifies all of them
behind the shared `getFieldUniversalIdentifier` derivation:

```
universalIdentifier = f(applicationUniversalIdentifier, objectUniversalIdentifier, fieldName)
```

## Ownership model

The rollout is built on an explicit split of who owns a field's
universal identifier:

- **The 8 system fields** (`id`, `createdAt`, `updatedAt`, `deletedAt`,
`createdBy`, `updatedBy`, `position`, `searchVector`) are
**server-owned**. Their universal identifiers are always the
deterministic derivation, on **every** application (standard,
workspace-custom, installed). Clients cannot provide custom values: a
temporary check in `validateObjectMetadataSystemFieldsIntegrity` rejects
any non-derived system field identifier at migration build time. This
check stands in until system fields are generated exclusively server
side by the metadata side-effect engine and stripped from client inputs
— at which point it becomes structurally impossible to send one.
- **`name` is a default field, not a system field**: it is
auto-provisioned when absent (server side for custom objects, SDK side
for application objects) but authors can define their own. It is only
derived where it is guaranteed to be auto-provisioned. In particular,
standard objects keep their **historical hardcoded** `name` identifiers:
the standard app authors its `name` fields like any installed app would,
and moving those identifiers would break every installed application
referencing them (e.g. views on `opportunity.name`).
- **User-created and author-provided fields** keep random / explicit
identifiers, untouched.

## Server

- `validateObjectMetadataSystemFieldsIntegrity` now validates, on top of
the existing type/`isSystem` checks, that each system field's
`universalIdentifier` equals the deterministic derivation. Runs for
every object creation going through the migration orchestrator: app
sync, custom object creation, standard provisioning
- `build-default-flat-field-metadatas-for-custom-object.util.ts` derives
the system field identifiers (and the auto-provisioned `name`) with
`getFieldUniversalIdentifier` instead of `v4()`
-
`build-default-relation-flat-field-metadatas-for-custom-object.util.ts`
derives both the forward and the reverse default relation field
identifiers deterministically
- `generateMorphOrRelationFlatFieldMetadataPair` accepts optional
`sourceFieldUniversalIdentifier` / `targetFieldUniversalIdentifier` so
callers can inject deterministic values; user-created relations still
default to `v4()`

## twenty-shared

- `STANDARD_OBJECTS` system field identifiers (the 8) are now computed
at module load via `buildStandardObjectSystemFields`; `name` and every
other identifier keep their hardcoded values
- New snapshot test pinning **every** universal identifier of
`STANDARD_OBJECTS`: any identifier change now requires an explicit
snapshot update and should ship with a coordinated backfill

## SDK (breaking, pre-GA)

- `generateDefaultFieldUniversalIdentifier` delegates to
`getFieldUniversalIdentifier` and now requires
`applicationUniversalIdentifier`
- Reverse default relation field identifiers are derived from the
field's real coordinates (standard object UID + actual field name, e.g.
`targetRocket` on `attachment`) instead of the legacy custom-object UID
+ synthetic `${fieldName}Inverse` hash input. Field *names* are
unchanged
- The manifest build threads the application universal identifier
through default field injection (two-pass over object configs)
- `twenty dev:add` now resolves the application universal identifier
upfront and refuses to scaffold anything until `defineApplication`
declares one — no more `fill-later` placeholder for the app UID in
generated files

## Upgrade

A 2.19 **workspace command** backfills existing
`fieldMetadata.universalIdentifier` rows to the deterministic
derivation. Coverage follows the ownership model:

- **The 8 system fields**: taken over for **every application**,
whatever value they currently hold. This is both safe and required now
that sync rejects non-derived values — leaving a row unconverged would
make its application unsyncable
- **`name`**: workspace-custom app → always taken over
(server-generated, no author to clobber); installed applications → only
rows still carrying the legacy SDK derivation are recomputed,
author-provided identifiers are never touched; standard app → never
touched (hardcoded in `STANDARD_OBJECTS`)
- **Default relation fields**: workspace-custom app → forward fields on
custom objects and reverse fields on the standard relation objects;
installed applications → legacy-derivation probe only

All identifiers of a workspace are updated inside a single transaction,
then the command flushes the field-metadata-related workspace caches and
bumps the metadata version.

Stored `applicationRegistration.manifest` snapshots are intentionally
**not** rewritten: installs and upgrades always sync from the
`manifest.json` inside the resolved package (npm/tarball), the stored
column is only used for display/marketplace purposes.

## Breaking behavior for old packages (fail closed)

Packages built with an older SDK carry legacy system field identifiers
in their tarball `manifest.json`. Installing or upgrading such a package
now fails with an explicit `INVALID_SYSTEM_FIELD` validation error
("universal identifier is not deterministic") instead of silently
mismatching against the backfilled rows and triggering a destructive
delete+create. The remediation is to rebuild the package with the new
SDK; the backfill has already converged the installed rows, so the
rebuilt manifest syncs cleanly.

## Test plan

- [x] `twenty-sdk` unit tests (526 tests) and typecheck
- [x] `twenty-shared` unit tests (1635 tests) including the
`STANDARD_OBJECTS` snapshot; `name` identifiers verified byte-for-byte
identical to `main`
- [x] Lint and typecheck clean on all touched packages
- [x] Integration: create a custom object and verify system + default
relation field identifiers match the deterministic derivation
(`create-one-object-metadata-deterministic-field-universal-identifiers`,
13 assertions passing)
- [x] Integration: `failing-sync-application-object-system-fields`
extended with a non-derived system field identifier case; all
identifiers in the spec pinned deterministically so snapshots embedding
expected/actual values are stable across runs (verified with a double
run)
- [x] Integration: all application sync suites pass with the derived
system field identifiers now required by the
`buildDefaultObjectManifest` test helper (9 suites, 20 tests)
- [x] Full test-database reset: standard app provisioning and seeded
workspaces pass the new validation
- [x] SDK manifest build verified on the postcard example app: all
auto-generated default field identifiers match the derivation
- [ ] Run
`upgrade:2-19:backfill-deterministic-field-universal-identifiers`
(dry-run then real) on a seeded workspace and verify identifier
convergence with a rebuilt app manifest
This commit is contained in:
Paul Rastoin
2026-07-06 15:34:33 +02:00
committed by GitHub
parent 0706c7c1bc
commit 6c40c7b91a
68 changed files with 5060 additions and 1336 deletions
@@ -11,7 +11,9 @@ import { PARTNER_ON_OPPORTUNITY_FIELD_ID } from 'src/fields/partner-on-opportuni
export const MY_DEALS_VIEW_UNIVERSAL_IDENTIFIER =
'09c9b985-6dce-43ec-a6b3-5e878e1aec74';
const OPPORTUNITY_NAME_FIELD_ID = '20202020-8609-4f65-a2d9-44009eb422b5';
const OPPORTUNITY_NAME_FIELD_ID =
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.fields.name
.universalIdentifier;
const OPPORTUNITY_AMOUNT_FIELD_ID = '20202020-583e-4642-8533-db761d5fa82f';
// Partner-facing list of opportunities assigned to the partner (partner relation set).
@@ -24,7 +24,7 @@ export default defineView({
position: 5,
fields: [
// Name
{ universalIdentifier: '74595afa-c9c4-45b6-b362-803fb5574fbd', fieldMetadataUniversalIdentifier: '20202020-8609-4f65-a2d9-44009eb422b5', position: 0, isVisible: true, size: 200 },
{ universalIdentifier: '74595afa-c9c4-45b6-b362-803fb5574fbd', fieldMetadataUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.fields.name.universalIdentifier, position: 0, isVisible: true, size: 200 },
// Need (B1 brief field)
{ universalIdentifier: 'a24d52c4-9d0b-4145-a8fb-a4e21e002f58', fieldMetadataUniversalIdentifier: OPPORTUNITY_NEED_FIELD_ID, position: 1, isVisible: true, size: 280 },
// Requirements (B1 brief field)
@@ -19,6 +19,7 @@ import { getJestMetadataAndApolloMocksAndCommandMenuWrapper } from '~/testing/je
import { getTestEnrichedObjectMetadataItemsMock } from '~/testing/utils/getTestEnrichedObjectMetadataItemsMock';
jest.mock('uuid', () => ({
...jest.requireActual('uuid'),
v4: jest.fn().mockReturnValue('mocked-uuid'),
}));
@@ -12,6 +12,7 @@ import { useRefetchAggregateQueries } from '@/object-record/hooks/useRefetchAggr
import { getJestMetadataAndApolloMocksWrapper } from '~/testing/jest/getJestMetadataAndApolloMocksWrapper';
jest.mock('uuid', () => ({
...jest.requireActual('uuid'),
v4: jest.fn(),
}));
@@ -12,6 +12,7 @@ import { getJestMetadataAndApolloMocksWrapper } from '~/testing/jest/getJestMeta
const PERSON_ID = 'a7286b9a-c039-4a89-9567-2dfa7953cda9';
jest.mock('uuid', () => ({
...jest.requireActual('uuid'),
v4: jest.fn(() => 'a7286b9a-c039-4a89-9567-2dfa7953cda9'),
}));
@@ -11,6 +11,7 @@ import { getJestMetadataAndApolloMocksWrapper } from '~/testing/jest/getJestMeta
const COMPANY_ID = 'cb2e9f4b-20c3-4759-9315-4ffeecfaf71a';
jest.mock('uuid', () => ({
...jest.requireActual('uuid'),
v4: jest.fn(() => 'cb2e9f4b-20c3-4759-9315-4ffeecfaf71a'),
}));
@@ -20,6 +20,7 @@ import {
} from './PageLayoutTestWrapper';
jest.mock('uuid', () => ({
...jest.requireActual('uuid'),
v4: jest.fn(() => 'mock-uuid'),
}));
@@ -16,6 +16,7 @@ import {
} from './PageLayoutTestWrapper';
jest.mock('uuid', () => ({
...jest.requireActual('uuid'),
v4: jest.fn(),
}));
@@ -27,6 +27,7 @@ import {
} from './PageLayoutTestWrapper';
jest.mock('uuid', () => ({
...jest.requireActual('uuid'),
v4: jest.fn(),
}));
@@ -14,6 +14,7 @@ import { getJestMetadataAndApolloMocksAndCommandMenuWrapper } from '~/testing/je
import { getTestEnrichedObjectMetadataItemsMock } from '~/testing/utils/getTestEnrichedObjectMetadataItemsMock';
jest.mock('uuid', () => ({
...jest.requireActual('uuid'),
v4: jest.fn().mockReturnValue('mocked-uuid'),
}));
@@ -20,6 +20,7 @@ import { getJestMetadataAndApolloMocksAndCommandMenuWrapper } from '~/testing/je
import { getTestEnrichedObjectMetadataItemsMock } from '~/testing/utils/getTestEnrichedObjectMetadataItemsMock';
jest.mock('uuid', () => ({
...jest.requireActual('uuid'),
v4: jest.fn().mockReturnValue('mocked-uuid'),
}));
@@ -20,6 +20,7 @@ import { getJestMetadataAndApolloMocksAndCommandMenuWrapper } from '~/testing/je
import { getTestEnrichedObjectMetadataItemsMock } from '~/testing/utils/getTestEnrichedObjectMetadataItemsMock';
jest.mock('uuid', () => ({
...jest.requireActual('uuid'),
v4: jest.fn().mockReturnValue('mocked-uuid'),
}));
@@ -13,7 +13,10 @@ import {
} from '@/ui/utilities/state/jotai/jotaiStore';
const mockedUuid = 'mocked-uuid';
jest.mock('uuid');
jest.mock('uuid', () => ({
...jest.requireActual('uuid'),
v4: jest.fn(),
}));
(uuidv4 as jest.Mock).mockReturnValue(mockedUuid);
@@ -7,7 +7,10 @@ import { FieldMetadataType } from 'twenty-shared/types';
import { StepStatus, type WorkflowRunStepInfos } from 'twenty-shared/workflow';
import { v4 as uuidv4 } from 'uuid';
jest.mock('uuid');
jest.mock('uuid', () => ({
...jest.requireActual('uuid'),
v4: jest.fn(),
}));
beforeEach(() => {
let counter = 0;
@@ -1,7 +1,10 @@
import { getWorkflowVersionDiagram } from '@/workflow/workflow-diagram/utils/getWorkflowVersionDiagram';
import { v4 as uuidv4 } from 'uuid';
jest.mock('uuid');
jest.mock('uuid', () => ({
...jest.requireActual('uuid'),
v4: jest.fn(),
}));
beforeEach(() => {
let counter = 0;
@@ -32,7 +32,10 @@ jest.mock('@/workflow/hooks/useGetUpdatableWorkflowVersionOrThrow', () => ({
}),
}));
jest.mock('uuid', () => ({ v4: () => 'step-id' }));
jest.mock('uuid', () => ({
...jest.requireActual('uuid'),
v4: () => 'step-id',
}));
const wrapper = ({ children }: { children: React.ReactNode }) => {
const workflowVisualizerComponentInstanceId =
@@ -3,6 +3,7 @@ import { v4 } from 'uuid';
import { getDefaultFormFieldSettings } from '@/workflow/workflow-steps/workflow-actions/form-action/utils/getDefaultFormFieldSettings';
jest.mock('uuid', () => ({
...jest.requireActual('uuid'),
v4: jest.fn(() => 'test-uuid-123'),
}));
@@ -33,10 +33,10 @@ export const EXPECTED_MANIFEST: Manifest = {
onDelete: RelationOnDeleteAction.SET_NULL,
joinColumnName: 'targetMyNoteId',
},
universalIdentifier: '7e28b1ea-9a94-5d36-bc03-3f12f476867a',
universalIdentifier: '55153e55-1bdc-590d-8fb1-40eafec88ef0',
objectUniversalIdentifier: '20202020-6736-4337-b5c4-8b39fae325a5',
relationTargetFieldMetadataUniversalIdentifier:
'12c84c1c-6e3f-54c5-a9a8-e93c7b46ac35',
'559713af-d9c0-5894-a972-9a3951661a2b',
relationTargetObjectMetadataUniversalIdentifier:
'e1e2e3e4-e5e6-4000-8000-000000000030',
type: FieldMetadataType.MORPH_RELATION,
@@ -53,10 +53,10 @@ export const EXPECTED_MANIFEST: Manifest = {
onDelete: RelationOnDeleteAction.SET_NULL,
joinColumnName: 'targetMyNoteId',
},
universalIdentifier: '721b12d8-7a3d-5e13-b8d5-62b3ad8cb8b5',
universalIdentifier: '80596d88-f896-5108-8cff-302a2eb61668',
objectUniversalIdentifier: '20202020-bd3d-4c60-8dca-571c71d4447a',
relationTargetFieldMetadataUniversalIdentifier:
'56a872c8-7a1e-563d-8772-3978c2c5c220',
'8f8e42e3-77de-5b0a-b444-d96b6d3c7600',
relationTargetObjectMetadataUniversalIdentifier:
'e1e2e3e4-e5e6-4000-8000-000000000030',
type: FieldMetadataType.MORPH_RELATION,
@@ -73,10 +73,10 @@ export const EXPECTED_MANIFEST: Manifest = {
onDelete: RelationOnDeleteAction.SET_NULL,
joinColumnName: 'targetMyNoteId',
},
universalIdentifier: '94b6f269-fc9a-5bcd-af5b-2bdac69f276b',
universalIdentifier: '67bf90bd-f3d0-58ce-a96e-648364249812',
objectUniversalIdentifier: '20202020-fff0-4b44-be82-bda313884400',
relationTargetFieldMetadataUniversalIdentifier:
'011595e8-7524-5ec7-9fbc-4bd3c42d7d24',
'0cbc9a5c-34b2-5d39-97a2-878662cb13d5',
relationTargetObjectMetadataUniversalIdentifier:
'e1e2e3e4-e5e6-4000-8000-000000000030',
type: FieldMetadataType.MORPH_RELATION,
@@ -93,10 +93,10 @@ export const EXPECTED_MANIFEST: Manifest = {
onDelete: RelationOnDeleteAction.SET_NULL,
joinColumnName: 'targetMyNoteId',
},
universalIdentifier: '50da3cfa-dea5-5c9b-b1b7-cb615e1df4c3',
universalIdentifier: '2689365b-e6ce-5b14-a2b5-6daafbfe91f8',
objectUniversalIdentifier: '20202020-5a9a-44e8-95df-771cd06d0fb1',
relationTargetFieldMetadataUniversalIdentifier:
'dd734fa3-26c3-53dd-91aa-577c313e1fa4',
'affdd37c-4460-53fc-998b-ffb04bb3a470',
relationTargetObjectMetadataUniversalIdentifier:
'e1e2e3e4-e5e6-4000-8000-000000000030',
type: FieldMetadataType.MORPH_RELATION,
@@ -129,7 +129,7 @@ export const EXPECTED_MANIFEST: Manifest = {
label: 'Id',
name: 'id',
type: FieldMetadataType.UUID,
universalIdentifier: '5bdb4f57-a349-56d0-b421-845fd55b3026',
universalIdentifier: 'a717a3ba-e257-5269-95c4-1b8ca4cb8bcf',
},
{
defaultValue: null,
@@ -139,7 +139,7 @@ export const EXPECTED_MANIFEST: Manifest = {
label: 'Name',
name: 'name',
type: FieldMetadataType.TEXT,
universalIdentifier: '86cc7b06-26de-5b7b-bf4a-31fa246dfd90',
universalIdentifier: '9a7f457a-6c15-581e-8f28-e2c680f75f97',
},
{
defaultValue: 'now',
@@ -149,7 +149,7 @@ export const EXPECTED_MANIFEST: Manifest = {
label: 'Creation date',
name: 'createdAt',
type: FieldMetadataType.DATE_TIME,
universalIdentifier: 'abe3bc69-1a51-5814-a722-74f5a025e92a',
universalIdentifier: 'f24a236a-e36a-5bab-91db-d43e3f4b8247',
},
{
defaultValue: 'now',
@@ -159,7 +159,7 @@ export const EXPECTED_MANIFEST: Manifest = {
label: 'Last update',
name: 'updatedAt',
type: FieldMetadataType.DATE_TIME,
universalIdentifier: 'c296d408-9720-5a86-8586-3490fbf04b86',
universalIdentifier: '875c1f2e-19f0-5200-96ba-ac1cb4c1fd33',
},
{
defaultValue: null,
@@ -169,7 +169,7 @@ export const EXPECTED_MANIFEST: Manifest = {
label: 'Deleted at',
name: 'deletedAt',
type: FieldMetadataType.DATE_TIME,
universalIdentifier: '488333a2-f4ff-5790-88ef-21f07f8f1985',
universalIdentifier: 'cc58b328-40e1-5087-b550-ad700e53490b',
},
{
defaultValue: {
@@ -182,7 +182,7 @@ export const EXPECTED_MANIFEST: Manifest = {
label: 'Created by',
name: 'createdBy',
type: FieldMetadataType.ACTOR,
universalIdentifier: 'fba6e01b-6579-554e-ad64-a676003c8e68',
universalIdentifier: '6f34f940-966a-52c2-a8f2-f2f04115d88e',
},
{
defaultValue: {
@@ -195,7 +195,7 @@ export const EXPECTED_MANIFEST: Manifest = {
label: 'Updated by',
name: 'updatedBy',
type: FieldMetadataType.ACTOR,
universalIdentifier: 'fa60f87d-15c8-5ff7-9da0-191fa14a922f',
universalIdentifier: '37515dfb-8ae0-5326-a391-9e66205f6530',
},
{
defaultValue: 0,
@@ -205,7 +205,7 @@ export const EXPECTED_MANIFEST: Manifest = {
label: 'Position',
name: 'position',
type: FieldMetadataType.POSITION,
universalIdentifier: '4a5d7791-ddc9-53ee-bc46-699da7373290',
universalIdentifier: 'a418b503-74d0-500d-b4c0-75f364394e20',
},
{
defaultValue: null,
@@ -215,7 +215,7 @@ export const EXPECTED_MANIFEST: Manifest = {
label: 'Search vector',
name: 'searchVector',
type: FieldMetadataType.TS_VECTOR,
universalIdentifier: '0f00bc3b-f313-5206-8496-842af20a538a',
universalIdentifier: '45718beb-e0d7-5bde-811c-29ff01b0fcfd',
},
{
name: 'timelineActivities',
@@ -227,9 +227,9 @@ export const EXPECTED_MANIFEST: Manifest = {
universalSettings: {
relationType: RelationType.ONE_TO_MANY,
},
universalIdentifier: '12c84c1c-6e3f-54c5-a9a8-e93c7b46ac35',
universalIdentifier: '559713af-d9c0-5894-a972-9a3951661a2b',
relationTargetFieldMetadataUniversalIdentifier:
'7e28b1ea-9a94-5d36-bc03-3f12f476867a',
'55153e55-1bdc-590d-8fb1-40eafec88ef0',
relationTargetObjectMetadataUniversalIdentifier:
'20202020-6736-4337-b5c4-8b39fae325a5',
},
@@ -243,9 +243,9 @@ export const EXPECTED_MANIFEST: Manifest = {
universalSettings: {
relationType: RelationType.ONE_TO_MANY,
},
universalIdentifier: '56a872c8-7a1e-563d-8772-3978c2c5c220',
universalIdentifier: '8f8e42e3-77de-5b0a-b444-d96b6d3c7600',
relationTargetFieldMetadataUniversalIdentifier:
'721b12d8-7a3d-5e13-b8d5-62b3ad8cb8b5',
'80596d88-f896-5108-8cff-302a2eb61668',
relationTargetObjectMetadataUniversalIdentifier:
'20202020-bd3d-4c60-8dca-571c71d4447a',
},
@@ -259,9 +259,9 @@ export const EXPECTED_MANIFEST: Manifest = {
universalSettings: {
relationType: RelationType.ONE_TO_MANY,
},
universalIdentifier: '011595e8-7524-5ec7-9fbc-4bd3c42d7d24',
universalIdentifier: '0cbc9a5c-34b2-5d39-97a2-878662cb13d5',
relationTargetFieldMetadataUniversalIdentifier:
'94b6f269-fc9a-5bcd-af5b-2bdac69f276b',
'67bf90bd-f3d0-58ce-a96e-648364249812',
relationTargetObjectMetadataUniversalIdentifier:
'20202020-fff0-4b44-be82-bda313884400',
},
@@ -275,9 +275,9 @@ export const EXPECTED_MANIFEST: Manifest = {
universalSettings: {
relationType: RelationType.ONE_TO_MANY,
},
universalIdentifier: 'dd734fa3-26c3-53dd-91aa-577c313e1fa4',
universalIdentifier: 'affdd37c-4460-53fc-998b-ffb04bb3a470',
relationTargetFieldMetadataUniversalIdentifier:
'50da3cfa-dea5-5c9b-b1b7-cb615e1df4c3',
'2689365b-e6ce-5b14-a2b5-6daafbfe91f8',
relationTargetObjectMetadataUniversalIdentifier:
'20202020-5a9a-44e8-95df-771cd06d0fb1',
},
@@ -292,11 +292,11 @@ export const EXPECTED_MANIFEST: Manifest = {
name: 'targetRootNote',
objectUniversalIdentifier: '20202020-6736-4337-b5c4-8b39fae325a5',
relationTargetFieldMetadataUniversalIdentifier:
'1cdb0b3a-0ae1-59f6-b3f0-59ff99e40525',
'e1478d59-c947-5926-af8b-4b2f0f842c91',
relationTargetObjectMetadataUniversalIdentifier:
'b0b1b2b3-b4b5-4000-8000-000000000001',
type: FieldType.MORPH_RELATION,
universalIdentifier: 'a0f6ffdc-a570-58a6-973d-a9bc9a527de8',
universalIdentifier: '7f891268-9376-5d00-b8ec-bc32a023154d',
universalSettings: {
joinColumnName: 'targetRootNoteId',
onDelete: RelationOnDeleteAction.SET_NULL,
@@ -312,11 +312,11 @@ export const EXPECTED_MANIFEST: Manifest = {
name: 'targetRootNote',
objectUniversalIdentifier: '20202020-bd3d-4c60-8dca-571c71d4447a',
relationTargetFieldMetadataUniversalIdentifier:
'aa05ee89-bd8a-5911-a4d5-3355926186bc',
'34a6b8e9-0187-57e1-86c1-41bacf1b2479',
relationTargetObjectMetadataUniversalIdentifier:
'b0b1b2b3-b4b5-4000-8000-000000000001',
type: FieldType.MORPH_RELATION,
universalIdentifier: '95cad10a-bff6-524d-b6d8-dbc6db7cbf18',
universalIdentifier: 'd72bb002-9952-5651-983e-649436949113',
universalSettings: {
joinColumnName: 'targetRootNoteId',
onDelete: RelationOnDeleteAction.SET_NULL,
@@ -332,11 +332,11 @@ export const EXPECTED_MANIFEST: Manifest = {
name: 'targetRootNote',
objectUniversalIdentifier: '20202020-fff0-4b44-be82-bda313884400',
relationTargetFieldMetadataUniversalIdentifier:
'762ad37e-d624-51d8-989c-3a119eef9e7f',
'7a62bbd6-e697-54b0-8c1c-163ac47c0305',
relationTargetObjectMetadataUniversalIdentifier:
'b0b1b2b3-b4b5-4000-8000-000000000001',
type: FieldType.MORPH_RELATION,
universalIdentifier: 'c327faff-76eb-5baf-8b35-1c31e57306fb',
universalIdentifier: '61252ad6-2311-5054-ae51-b318dfd5fbd8',
universalSettings: {
joinColumnName: 'targetRootNoteId',
onDelete: RelationOnDeleteAction.SET_NULL,
@@ -352,11 +352,11 @@ export const EXPECTED_MANIFEST: Manifest = {
name: 'targetRootNote',
objectUniversalIdentifier: '20202020-5a9a-44e8-95df-771cd06d0fb1',
relationTargetFieldMetadataUniversalIdentifier:
'd4f1b0c8-8cd9-581e-9bf9-917f8c918d76',
'10c1af7e-e541-56bc-b5b3-d0297df3e23d',
relationTargetObjectMetadataUniversalIdentifier:
'b0b1b2b3-b4b5-4000-8000-000000000001',
type: FieldType.MORPH_RELATION,
universalIdentifier: '36843050-e6e3-5759-99b6-e975426bad73',
universalIdentifier: '2f4f3d69-b78e-5465-9e87-17059959fca8',
universalSettings: {
joinColumnName: 'targetRootNoteId',
onDelete: RelationOnDeleteAction.SET_NULL,
@@ -480,11 +480,11 @@ export const EXPECTED_MANIFEST: Manifest = {
name: 'targetPostCardRecipient',
objectUniversalIdentifier: '20202020-6736-4337-b5c4-8b39fae325a5',
relationTargetFieldMetadataUniversalIdentifier:
'02f5efba-4f94-5cc3-9e17-ca7b1fe5f71b',
'8f95b434-ee92-5c0f-95de-9d489af4d40c',
relationTargetObjectMetadataUniversalIdentifier:
'e1a2b3c4-5e6f-4a7b-8c9d-0e1f2a3b4c5e',
type: FieldType.MORPH_RELATION,
universalIdentifier: '18b6354d-8b0f-5be6-9473-d635aed670a1',
universalIdentifier: 'a865f403-3843-5850-9a60-04e832220954',
universalSettings: {
joinColumnName: 'targetPostCardRecipientId',
onDelete: RelationOnDeleteAction.SET_NULL,
@@ -500,11 +500,11 @@ export const EXPECTED_MANIFEST: Manifest = {
name: 'targetPostCardRecipient',
objectUniversalIdentifier: '20202020-bd3d-4c60-8dca-571c71d4447a',
relationTargetFieldMetadataUniversalIdentifier:
'82497ec7-0121-53d6-9518-ae7434ceaf8b',
'0c20df83-8cef-5cd6-9752-b98fb7780904',
relationTargetObjectMetadataUniversalIdentifier:
'e1a2b3c4-5e6f-4a7b-8c9d-0e1f2a3b4c5e',
type: FieldType.MORPH_RELATION,
universalIdentifier: 'ba74dfd1-e7ee-502d-abad-82e14d595874',
universalIdentifier: '501c46b7-7140-59a0-b703-9844bbcb34b9',
universalSettings: {
joinColumnName: 'targetPostCardRecipientId',
onDelete: RelationOnDeleteAction.SET_NULL,
@@ -520,11 +520,11 @@ export const EXPECTED_MANIFEST: Manifest = {
name: 'targetPostCardRecipient',
objectUniversalIdentifier: '20202020-fff0-4b44-be82-bda313884400',
relationTargetFieldMetadataUniversalIdentifier:
'6de8b57e-ed0a-5d35-bd39-bd8874445810',
'b9642a90-2df5-555b-ab83-23cdce7a4142',
relationTargetObjectMetadataUniversalIdentifier:
'e1a2b3c4-5e6f-4a7b-8c9d-0e1f2a3b4c5e',
type: FieldType.MORPH_RELATION,
universalIdentifier: '9d5a9f4b-5431-5b90-810a-1e9d8873b302',
universalIdentifier: '721418ff-1b53-58ef-8dd7-b58927c085c6',
universalSettings: {
joinColumnName: 'targetPostCardRecipientId',
onDelete: RelationOnDeleteAction.SET_NULL,
@@ -540,11 +540,11 @@ export const EXPECTED_MANIFEST: Manifest = {
name: 'targetPostCardRecipient',
objectUniversalIdentifier: '20202020-5a9a-44e8-95df-771cd06d0fb1',
relationTargetFieldMetadataUniversalIdentifier:
'00459e7e-8ef1-5a9b-b180-26bbb54a2696',
'd938a2d0-1279-5c48-b758-ac52f9460a76',
relationTargetObjectMetadataUniversalIdentifier:
'e1a2b3c4-5e6f-4a7b-8c9d-0e1f2a3b4c5e',
type: FieldType.MORPH_RELATION,
universalIdentifier: '85353e69-1e70-51ef-91c3-334d85013a4b',
universalIdentifier: 'feeba797-7ebb-5a36-91cd-7279ff92a2b6',
universalSettings: {
joinColumnName: 'targetPostCardRecipientId',
onDelete: RelationOnDeleteAction.SET_NULL,
@@ -561,11 +561,11 @@ export const EXPECTED_MANIFEST: Manifest = {
name: 'targetPostCard',
objectUniversalIdentifier: '20202020-6736-4337-b5c4-8b39fae325a5',
relationTargetFieldMetadataUniversalIdentifier:
'3f8c70b5-abb0-51ec-9109-0a94ce9c04a6',
'9e58820c-78d7-539d-807c-d84362e1eab6',
relationTargetObjectMetadataUniversalIdentifier:
'54b589ca-eeed-4950-a176-358418b85c05',
type: FieldType.MORPH_RELATION,
universalIdentifier: '1bdc52a0-0339-5360-ba17-b615250ef730',
universalIdentifier: '47d21bb5-075f-5ed4-ba89-c0e7322a57cd',
universalSettings: {
joinColumnName: 'targetPostCardId',
onDelete: RelationOnDeleteAction.SET_NULL,
@@ -581,11 +581,11 @@ export const EXPECTED_MANIFEST: Manifest = {
name: 'targetPostCard',
objectUniversalIdentifier: '20202020-bd3d-4c60-8dca-571c71d4447a',
relationTargetFieldMetadataUniversalIdentifier:
'547c93e4-c9e7-5880-b251-46230fb59bfa',
'3f0d38c8-a50f-51c7-bdcb-84a715f4b92d',
relationTargetObjectMetadataUniversalIdentifier:
'54b589ca-eeed-4950-a176-358418b85c05',
type: FieldType.MORPH_RELATION,
universalIdentifier: 'fecb8484-7e3c-59f8-a5ef-30882c5c1904',
universalIdentifier: '4d3cf1bd-d87d-5325-8fbc-19f72c252050',
universalSettings: {
joinColumnName: 'targetPostCardId',
onDelete: RelationOnDeleteAction.SET_NULL,
@@ -601,11 +601,11 @@ export const EXPECTED_MANIFEST: Manifest = {
name: 'targetPostCard',
objectUniversalIdentifier: '20202020-fff0-4b44-be82-bda313884400',
relationTargetFieldMetadataUniversalIdentifier:
'47deea5c-8559-5ae7-991a-60c9f7102f86',
'def1f10a-847b-574e-a0a2-177caf02129c',
relationTargetObjectMetadataUniversalIdentifier:
'54b589ca-eeed-4950-a176-358418b85c05',
type: FieldType.MORPH_RELATION,
universalIdentifier: 'ca687361-ac2b-5722-9ad9-cd37427d087a',
universalIdentifier: 'bc1f9abe-e27b-574a-9290-a1c9c8255a6b',
universalSettings: {
joinColumnName: 'targetPostCardId',
onDelete: RelationOnDeleteAction.SET_NULL,
@@ -621,11 +621,11 @@ export const EXPECTED_MANIFEST: Manifest = {
name: 'targetPostCard',
objectUniversalIdentifier: '20202020-5a9a-44e8-95df-771cd06d0fb1',
relationTargetFieldMetadataUniversalIdentifier:
'ecdccc0d-7c31-5126-89d5-0bdf3372257c',
'b1238c2d-bc9c-53d6-8bee-eb045b9b9086',
relationTargetObjectMetadataUniversalIdentifier:
'54b589ca-eeed-4950-a176-358418b85c05',
type: FieldType.MORPH_RELATION,
universalIdentifier: '17058c96-d5a4-53b1-96ca-786f8c5fc8bd',
universalIdentifier: 'fb610de1-db20-5e78-8e4a-7630e57148eb',
universalSettings: {
joinColumnName: 'targetPostCardId',
onDelete: RelationOnDeleteAction.SET_NULL,
@@ -642,11 +642,11 @@ export const EXPECTED_MANIFEST: Manifest = {
name: 'targetRecipient',
objectUniversalIdentifier: '20202020-6736-4337-b5c4-8b39fae325a5',
relationTargetFieldMetadataUniversalIdentifier:
'51d6d2d7-2fa8-5f77-b6cf-a96dbb801fa5',
'1118dd8a-9781-5254-b625-2d0f05eb46fe',
relationTargetObjectMetadataUniversalIdentifier:
'd1a2b3c4-5e6f-4a7b-8c9d-0e1f2a3b4c5d',
type: FieldType.MORPH_RELATION,
universalIdentifier: 'b01f6937-4859-587b-b3df-92e0a6f65e93',
universalIdentifier: '1ce06ca1-6e9a-53ce-9888-9152b1b2b628',
universalSettings: {
joinColumnName: 'targetRecipientId',
onDelete: RelationOnDeleteAction.SET_NULL,
@@ -662,11 +662,11 @@ export const EXPECTED_MANIFEST: Manifest = {
name: 'targetRecipient',
objectUniversalIdentifier: '20202020-bd3d-4c60-8dca-571c71d4447a',
relationTargetFieldMetadataUniversalIdentifier:
'244a7841-9e45-5ed1-a830-83e73c1f13fa',
'15714c1a-d354-5407-8a15-25c117d605f8',
relationTargetObjectMetadataUniversalIdentifier:
'd1a2b3c4-5e6f-4a7b-8c9d-0e1f2a3b4c5d',
type: FieldType.MORPH_RELATION,
universalIdentifier: '9c87e1b8-74a2-5833-9237-281d561d2a7d',
universalIdentifier: '2081700d-7ce6-5698-a7da-a013feda0eb6',
universalSettings: {
joinColumnName: 'targetRecipientId',
onDelete: RelationOnDeleteAction.SET_NULL,
@@ -682,11 +682,11 @@ export const EXPECTED_MANIFEST: Manifest = {
name: 'targetRecipient',
objectUniversalIdentifier: '20202020-fff0-4b44-be82-bda313884400',
relationTargetFieldMetadataUniversalIdentifier:
'eab71eb1-b457-555a-8a2b-f292cef1671d',
'167f6de7-835e-51e1-b751-34055eef6aa5',
relationTargetObjectMetadataUniversalIdentifier:
'd1a2b3c4-5e6f-4a7b-8c9d-0e1f2a3b4c5d',
type: FieldType.MORPH_RELATION,
universalIdentifier: '23508618-7ed8-519e-9fee-a57b94dc98e9',
universalIdentifier: 'a93c479c-6206-5714-94ce-affa6e9886aa',
universalSettings: {
joinColumnName: 'targetRecipientId',
onDelete: RelationOnDeleteAction.SET_NULL,
@@ -702,11 +702,11 @@ export const EXPECTED_MANIFEST: Manifest = {
name: 'targetRecipient',
objectUniversalIdentifier: '20202020-5a9a-44e8-95df-771cd06d0fb1',
relationTargetFieldMetadataUniversalIdentifier:
'305f1890-04ba-5101-b18f-a1ca25a1d8a7',
'd0db1844-4fde-5a5a-b037-ebeabd4467af',
relationTargetObjectMetadataUniversalIdentifier:
'd1a2b3c4-5e6f-4a7b-8c9d-0e1f2a3b4c5d',
type: FieldType.MORPH_RELATION,
universalIdentifier: '20500ff3-2410-5bfc-b2e7-804785c07770',
universalIdentifier: '45752445-c4b6-5e80-8aa1-f5321a838b43',
universalSettings: {
joinColumnName: 'targetRecipientId',
onDelete: RelationOnDeleteAction.SET_NULL,
@@ -749,7 +749,7 @@ export const EXPECTED_MANIFEST: Manifest = {
label: 'Id',
name: 'id',
type: FieldMetadataType.UUID,
universalIdentifier: 'b5830f92-1abe-5ae7-b689-7821dda00b15',
universalIdentifier: 'a4429f96-5745-511a-b246-67da9f920452',
},
{
defaultValue: null,
@@ -759,7 +759,7 @@ export const EXPECTED_MANIFEST: Manifest = {
label: 'Name',
name: 'name',
type: FieldMetadataType.TEXT,
universalIdentifier: 'ae2b2e5a-6c8b-5324-b639-8f17e3bc0420',
universalIdentifier: '10452ca3-30cb-56fa-9e58-73f7b1c9fd65',
},
{
defaultValue: 'now',
@@ -769,7 +769,7 @@ export const EXPECTED_MANIFEST: Manifest = {
label: 'Creation date',
name: 'createdAt',
type: FieldMetadataType.DATE_TIME,
universalIdentifier: '7c84a501-41a6-531e-bc5e-8062843f6cba',
universalIdentifier: '777fa853-2198-5623-96f3-119ef5707ad3',
},
{
defaultValue: 'now',
@@ -779,7 +779,7 @@ export const EXPECTED_MANIFEST: Manifest = {
label: 'Last update',
name: 'updatedAt',
type: FieldMetadataType.DATE_TIME,
universalIdentifier: '72c2903a-91c9-5f36-8668-b53b083ae4cf',
universalIdentifier: 'e1715fe9-34e1-572c-b62c-f39d737a17df',
},
{
defaultValue: null,
@@ -789,7 +789,7 @@ export const EXPECTED_MANIFEST: Manifest = {
label: 'Deleted at',
name: 'deletedAt',
type: FieldMetadataType.DATE_TIME,
universalIdentifier: '007f5d8e-d376-519e-bd07-aea41a53f7d9',
universalIdentifier: 'e3b13370-7241-55f1-ab9a-346b57bf4dcf',
},
{
defaultValue: {
@@ -802,7 +802,7 @@ export const EXPECTED_MANIFEST: Manifest = {
label: 'Created by',
name: 'createdBy',
type: FieldMetadataType.ACTOR,
universalIdentifier: '44053354-0d8b-5d66-bfa8-7e7d221de3db',
universalIdentifier: 'd2e3e238-9bfd-5845-9fe8-3e58df09f246',
},
{
defaultValue: {
@@ -815,7 +815,7 @@ export const EXPECTED_MANIFEST: Manifest = {
label: 'Updated by',
name: 'updatedBy',
type: FieldMetadataType.ACTOR,
universalIdentifier: '38e2c671-cbf5-5773-a04a-e5a2d6c323c8',
universalIdentifier: '690003ae-f649-537e-b124-d21ef0ee86a1',
},
{
defaultValue: 0,
@@ -825,7 +825,7 @@ export const EXPECTED_MANIFEST: Manifest = {
label: 'Position',
name: 'position',
type: FieldMetadataType.POSITION,
universalIdentifier: '3b56cdea-cf85-51d4-a7a0-cd701f76141a',
universalIdentifier: '4b6d383d-4e84-592e-b1ae-30ae27139302',
},
{
defaultValue: null,
@@ -835,7 +835,7 @@ export const EXPECTED_MANIFEST: Manifest = {
label: 'Search vector',
name: 'searchVector',
type: FieldMetadataType.TS_VECTOR,
universalIdentifier: '7c6d88f5-a348-51dd-8d0b-74a17e626754',
universalIdentifier: 'a92d59aa-23a1-5191-b840-933183c14846',
},
{
description: 'Root notes tied to the RootNote',
@@ -844,11 +844,11 @@ export const EXPECTED_MANIFEST: Manifest = {
label: 'Timeline Activities',
name: 'timelineActivities',
relationTargetFieldMetadataUniversalIdentifier:
'a0f6ffdc-a570-58a6-973d-a9bc9a527de8',
'7f891268-9376-5d00-b8ec-bc32a023154d',
relationTargetObjectMetadataUniversalIdentifier:
'20202020-6736-4337-b5c4-8b39fae325a5',
type: FieldType.RELATION,
universalIdentifier: '1cdb0b3a-0ae1-59f6-b3f0-59ff99e40525',
universalIdentifier: 'e1478d59-c947-5926-af8b-4b2f0f842c91',
universalSettings: {
relationType: RelationType.ONE_TO_MANY,
},
@@ -860,11 +860,11 @@ export const EXPECTED_MANIFEST: Manifest = {
label: 'Attachments',
name: 'attachments',
relationTargetFieldMetadataUniversalIdentifier:
'95cad10a-bff6-524d-b6d8-dbc6db7cbf18',
'd72bb002-9952-5651-983e-649436949113',
relationTargetObjectMetadataUniversalIdentifier:
'20202020-bd3d-4c60-8dca-571c71d4447a',
type: FieldType.RELATION,
universalIdentifier: 'aa05ee89-bd8a-5911-a4d5-3355926186bc',
universalIdentifier: '34a6b8e9-0187-57e1-86c1-41bacf1b2479',
universalSettings: {
relationType: RelationType.ONE_TO_MANY,
},
@@ -876,11 +876,11 @@ export const EXPECTED_MANIFEST: Manifest = {
label: 'Note Targets',
name: 'noteTargets',
relationTargetFieldMetadataUniversalIdentifier:
'c327faff-76eb-5baf-8b35-1c31e57306fb',
'61252ad6-2311-5054-ae51-b318dfd5fbd8',
relationTargetObjectMetadataUniversalIdentifier:
'20202020-fff0-4b44-be82-bda313884400',
type: FieldType.RELATION,
universalIdentifier: '762ad37e-d624-51d8-989c-3a119eef9e7f',
universalIdentifier: '7a62bbd6-e697-54b0-8c1c-163ac47c0305',
universalSettings: {
relationType: RelationType.ONE_TO_MANY,
},
@@ -892,11 +892,11 @@ export const EXPECTED_MANIFEST: Manifest = {
label: 'Task Targets',
name: 'taskTargets',
relationTargetFieldMetadataUniversalIdentifier:
'36843050-e6e3-5759-99b6-e975426bad73',
'2f4f3d69-b78e-5465-9e87-17059959fca8',
relationTargetObjectMetadataUniversalIdentifier:
'20202020-5a9a-44e8-95df-771cd06d0fb1',
type: FieldType.RELATION,
universalIdentifier: 'd4f1b0c8-8cd9-581e-9bf9-917f8c918d76',
universalIdentifier: '10c1af7e-e541-56bc-b5b3-d0297df3e23d',
universalSettings: {
relationType: RelationType.ONE_TO_MANY,
},
@@ -931,7 +931,7 @@ export const EXPECTED_MANIFEST: Manifest = {
label: 'Id',
name: 'id',
type: FieldMetadataType.UUID,
universalIdentifier: '2809d745-deb3-5ac0-8328-ea6256ec22b3',
universalIdentifier: 'fe2cec74-a43b-5910-8dd6-7a737fb869ae',
},
{
defaultValue: null,
@@ -941,7 +941,7 @@ export const EXPECTED_MANIFEST: Manifest = {
label: 'Name',
name: 'name',
type: FieldMetadataType.TEXT,
universalIdentifier: 'a6ee6cc8-b9a0-5e23-a39e-bc3fee2aa3d3',
universalIdentifier: 'f485cd3a-4c55-5b8f-b926-a83e5e6ba651',
},
{
defaultValue: 'now',
@@ -951,7 +951,7 @@ export const EXPECTED_MANIFEST: Manifest = {
label: 'Creation date',
name: 'createdAt',
type: FieldMetadataType.DATE_TIME,
universalIdentifier: '87ad23b6-b04e-5dc1-9907-c1eca54ea6d6',
universalIdentifier: '729b0d02-9030-5440-bd4e-f34b46d5d031',
},
{
defaultValue: 'now',
@@ -961,7 +961,7 @@ export const EXPECTED_MANIFEST: Manifest = {
label: 'Last update',
name: 'updatedAt',
type: FieldMetadataType.DATE_TIME,
universalIdentifier: '6d7523dc-c097-5b75-96eb-37502d09cc19',
universalIdentifier: '42381c24-6337-5654-8c44-6649fc6b06a8',
},
{
defaultValue: null,
@@ -971,7 +971,7 @@ export const EXPECTED_MANIFEST: Manifest = {
label: 'Deleted at',
name: 'deletedAt',
type: FieldMetadataType.DATE_TIME,
universalIdentifier: 'bd4fe2fa-373a-5fdd-ba09-cb5435781e4c',
universalIdentifier: '5117f25c-d3a2-533b-b3d6-39a424e88d2a',
},
{
defaultValue: {
@@ -984,7 +984,7 @@ export const EXPECTED_MANIFEST: Manifest = {
label: 'Created by',
name: 'createdBy',
type: FieldMetadataType.ACTOR,
universalIdentifier: 'c5026f20-ab7a-54e0-900e-37a2d5381aee',
universalIdentifier: '48df9616-8671-522f-87c7-efcd3f2e2604',
},
{
defaultValue: {
@@ -997,7 +997,7 @@ export const EXPECTED_MANIFEST: Manifest = {
label: 'Updated by',
name: 'updatedBy',
type: FieldMetadataType.ACTOR,
universalIdentifier: '1dd75ff5-5fd0-51df-b3a3-a12e3aff30df',
universalIdentifier: '5c8eb640-8c91-5712-8b73-85ce5e71abb9',
},
{
defaultValue: 0,
@@ -1007,7 +1007,7 @@ export const EXPECTED_MANIFEST: Manifest = {
label: 'Position',
name: 'position',
type: FieldMetadataType.POSITION,
universalIdentifier: '6c5e91f5-e323-555b-84e0-5a1dd7f36b1b',
universalIdentifier: '5a5800dd-743f-533d-ad5c-f983a4bb6324',
},
{
defaultValue: null,
@@ -1017,7 +1017,7 @@ export const EXPECTED_MANIFEST: Manifest = {
label: 'Search vector',
name: 'searchVector',
type: FieldMetadataType.TS_VECTOR,
universalIdentifier: '9fa18b19-41b6-5082-b3e2-4e73f7461164',
universalIdentifier: 'c4d0beaf-9035-514b-977c-5059b544c1bb',
},
{
description: 'Post Card Recipients tied to the PostCardRecipient',
@@ -1026,11 +1026,11 @@ export const EXPECTED_MANIFEST: Manifest = {
label: 'Timeline Activities',
name: 'timelineActivities',
relationTargetFieldMetadataUniversalIdentifier:
'18b6354d-8b0f-5be6-9473-d635aed670a1',
'a865f403-3843-5850-9a60-04e832220954',
relationTargetObjectMetadataUniversalIdentifier:
'20202020-6736-4337-b5c4-8b39fae325a5',
type: FieldType.RELATION,
universalIdentifier: '02f5efba-4f94-5cc3-9e17-ca7b1fe5f71b',
universalIdentifier: '8f95b434-ee92-5c0f-95de-9d489af4d40c',
universalSettings: {
relationType: RelationType.ONE_TO_MANY,
},
@@ -1042,11 +1042,11 @@ export const EXPECTED_MANIFEST: Manifest = {
label: 'Attachments',
name: 'attachments',
relationTargetFieldMetadataUniversalIdentifier:
'ba74dfd1-e7ee-502d-abad-82e14d595874',
'501c46b7-7140-59a0-b703-9844bbcb34b9',
relationTargetObjectMetadataUniversalIdentifier:
'20202020-bd3d-4c60-8dca-571c71d4447a',
type: FieldType.RELATION,
universalIdentifier: '82497ec7-0121-53d6-9518-ae7434ceaf8b',
universalIdentifier: '0c20df83-8cef-5cd6-9752-b98fb7780904',
universalSettings: {
relationType: RelationType.ONE_TO_MANY,
},
@@ -1058,11 +1058,11 @@ export const EXPECTED_MANIFEST: Manifest = {
label: 'Note Targets',
name: 'noteTargets',
relationTargetFieldMetadataUniversalIdentifier:
'9d5a9f4b-5431-5b90-810a-1e9d8873b302',
'721418ff-1b53-58ef-8dd7-b58927c085c6',
relationTargetObjectMetadataUniversalIdentifier:
'20202020-fff0-4b44-be82-bda313884400',
type: FieldType.RELATION,
universalIdentifier: '6de8b57e-ed0a-5d35-bd39-bd8874445810',
universalIdentifier: 'b9642a90-2df5-555b-ab83-23cdce7a4142',
universalSettings: {
relationType: RelationType.ONE_TO_MANY,
},
@@ -1074,11 +1074,11 @@ export const EXPECTED_MANIFEST: Manifest = {
label: 'Task Targets',
name: 'taskTargets',
relationTargetFieldMetadataUniversalIdentifier:
'85353e69-1e70-51ef-91c3-334d85013a4b',
'feeba797-7ebb-5a36-91cd-7279ff92a2b6',
relationTargetObjectMetadataUniversalIdentifier:
'20202020-5a9a-44e8-95df-771cd06d0fb1',
type: FieldType.RELATION,
universalIdentifier: '00459e7e-8ef1-5a9b-b180-26bbb54a2696',
universalIdentifier: 'd938a2d0-1279-5c48-b758-ac52f9460a76',
universalSettings: {
relationType: RelationType.ONE_TO_MANY,
},
@@ -1086,7 +1086,7 @@ export const EXPECTED_MANIFEST: Manifest = {
],
icon: 'IconLink',
labelIdentifierFieldMetadataUniversalIdentifier:
'a6ee6cc8-b9a0-5e23-a39e-bc3fee2aa3d3',
'f485cd3a-4c55-5b8f-b926-a83e5e6ba651',
labelPlural: 'Post Card Recipients',
labelSingular: 'Post Card Recipient',
namePlural: 'postCardRecipients',
@@ -1180,7 +1180,7 @@ export const EXPECTED_MANIFEST: Manifest = {
label: 'Id',
name: 'id',
type: FieldMetadataType.UUID,
universalIdentifier: '56e652a3-fac2-54fd-a379-f01b72758268',
universalIdentifier: 'e61745a2-969c-53d1-a809-6c583e9192d5',
},
{
defaultValue: null,
@@ -1190,7 +1190,7 @@ export const EXPECTED_MANIFEST: Manifest = {
label: 'Name',
name: 'name',
type: FieldMetadataType.TEXT,
universalIdentifier: 'c59a149f-5861-51a3-a924-2bd2fa31daaa',
universalIdentifier: '3e14e30f-8271-5a13-9c1f-ce5edb932325',
},
{
defaultValue: 'now',
@@ -1200,7 +1200,7 @@ export const EXPECTED_MANIFEST: Manifest = {
label: 'Creation date',
name: 'createdAt',
type: FieldMetadataType.DATE_TIME,
universalIdentifier: '236e2ec5-3636-5b97-b19d-f7799cb88d99',
universalIdentifier: 'b60eaf02-b008-5ebc-8fe5-123f8e92cc9e',
},
{
defaultValue: 'now',
@@ -1210,7 +1210,7 @@ export const EXPECTED_MANIFEST: Manifest = {
label: 'Last update',
name: 'updatedAt',
type: FieldMetadataType.DATE_TIME,
universalIdentifier: '2cf640eb-09a7-52f2-820b-419071c5ec24',
universalIdentifier: 'd76f15e3-e877-5b62-af97-9dac7c71065b',
},
{
defaultValue: null,
@@ -1220,7 +1220,7 @@ export const EXPECTED_MANIFEST: Manifest = {
label: 'Deleted at',
name: 'deletedAt',
type: FieldMetadataType.DATE_TIME,
universalIdentifier: '2eb62b4a-6888-58ab-9887-a879321fff31',
universalIdentifier: 'a5ec9b5d-90ed-587e-8a9b-cd9d1ba43116',
},
{
defaultValue: {
@@ -1233,7 +1233,7 @@ export const EXPECTED_MANIFEST: Manifest = {
label: 'Created by',
name: 'createdBy',
type: FieldMetadataType.ACTOR,
universalIdentifier: '36e95d21-9c86-5b6f-8133-a66d09fc8952',
universalIdentifier: '4c72ab36-ca4a-5870-b8cc-652385020c45',
},
{
defaultValue: {
@@ -1246,7 +1246,7 @@ export const EXPECTED_MANIFEST: Manifest = {
label: 'Updated by',
name: 'updatedBy',
type: FieldMetadataType.ACTOR,
universalIdentifier: 'dc5231a4-e128-57b0-ac9d-723678d9b239',
universalIdentifier: '2f481c85-025c-51cf-bccd-3375d252bf26',
},
{
defaultValue: 0,
@@ -1256,7 +1256,7 @@ export const EXPECTED_MANIFEST: Manifest = {
label: 'Position',
name: 'position',
type: FieldMetadataType.POSITION,
universalIdentifier: '3ad26df7-6345-5f85-aac1-2306cf10eaed',
universalIdentifier: 'e1178a62-d1a5-5e9c-b730-9118c640711d',
},
{
defaultValue: null,
@@ -1266,7 +1266,7 @@ export const EXPECTED_MANIFEST: Manifest = {
label: 'Search vector',
name: 'searchVector',
type: FieldMetadataType.TS_VECTOR,
universalIdentifier: 'b49b1a1c-2f6b-52a3-836b-ece89c698627',
universalIdentifier: 'e8569449-73b0-5f19-abc0-f6ad06c40341',
},
{
description: 'Post cards tied to the PostCard',
@@ -1275,11 +1275,11 @@ export const EXPECTED_MANIFEST: Manifest = {
label: 'Timeline Activities',
name: 'timelineActivities',
relationTargetFieldMetadataUniversalIdentifier:
'1bdc52a0-0339-5360-ba17-b615250ef730',
'47d21bb5-075f-5ed4-ba89-c0e7322a57cd',
relationTargetObjectMetadataUniversalIdentifier:
'20202020-6736-4337-b5c4-8b39fae325a5',
type: FieldType.RELATION,
universalIdentifier: '3f8c70b5-abb0-51ec-9109-0a94ce9c04a6',
universalIdentifier: '9e58820c-78d7-539d-807c-d84362e1eab6',
universalSettings: {
relationType: RelationType.ONE_TO_MANY,
},
@@ -1291,11 +1291,11 @@ export const EXPECTED_MANIFEST: Manifest = {
label: 'Attachments',
name: 'attachments',
relationTargetFieldMetadataUniversalIdentifier:
'fecb8484-7e3c-59f8-a5ef-30882c5c1904',
'4d3cf1bd-d87d-5325-8fbc-19f72c252050',
relationTargetObjectMetadataUniversalIdentifier:
'20202020-bd3d-4c60-8dca-571c71d4447a',
type: FieldType.RELATION,
universalIdentifier: '547c93e4-c9e7-5880-b251-46230fb59bfa',
universalIdentifier: '3f0d38c8-a50f-51c7-bdcb-84a715f4b92d',
universalSettings: {
relationType: RelationType.ONE_TO_MANY,
},
@@ -1307,11 +1307,11 @@ export const EXPECTED_MANIFEST: Manifest = {
label: 'Note Targets',
name: 'noteTargets',
relationTargetFieldMetadataUniversalIdentifier:
'ca687361-ac2b-5722-9ad9-cd37427d087a',
'bc1f9abe-e27b-574a-9290-a1c9c8255a6b',
relationTargetObjectMetadataUniversalIdentifier:
'20202020-fff0-4b44-be82-bda313884400',
type: FieldType.RELATION,
universalIdentifier: '47deea5c-8559-5ae7-991a-60c9f7102f86',
universalIdentifier: 'def1f10a-847b-574e-a0a2-177caf02129c',
universalSettings: {
relationType: RelationType.ONE_TO_MANY,
},
@@ -1323,11 +1323,11 @@ export const EXPECTED_MANIFEST: Manifest = {
label: 'Task Targets',
name: 'taskTargets',
relationTargetFieldMetadataUniversalIdentifier:
'17058c96-d5a4-53b1-96ca-786f8c5fc8bd',
'fb610de1-db20-5e78-8e4a-7630e57148eb',
relationTargetObjectMetadataUniversalIdentifier:
'20202020-5a9a-44e8-95df-771cd06d0fb1',
type: FieldType.RELATION,
universalIdentifier: 'ecdccc0d-7c31-5126-89d5-0bdf3372257c',
universalIdentifier: 'b1238c2d-bc9c-53d6-8bee-eb045b9b9086',
universalSettings: {
relationType: RelationType.ONE_TO_MANY,
},
@@ -1367,7 +1367,7 @@ export const EXPECTED_MANIFEST: Manifest = {
label: 'Id',
name: 'id',
type: FieldMetadataType.UUID,
universalIdentifier: '71aa41d7-54d0-5447-bf98-f37435b62bc4',
universalIdentifier: '6c8d489d-f871-598c-92b7-929b5883ae0f',
},
{
defaultValue: null,
@@ -1377,7 +1377,7 @@ export const EXPECTED_MANIFEST: Manifest = {
label: 'Name',
name: 'name',
type: FieldMetadataType.TEXT,
universalIdentifier: 'ff07c707-df79-58fa-91a4-c60c31f90e6e',
universalIdentifier: 'c400ccb8-cd91-5ae1-81e3-2df8b6f2cf53',
},
{
defaultValue: 'now',
@@ -1387,7 +1387,7 @@ export const EXPECTED_MANIFEST: Manifest = {
label: 'Creation date',
name: 'createdAt',
type: FieldMetadataType.DATE_TIME,
universalIdentifier: '8dd42a57-7aca-5e60-89c1-6e0e430dad43',
universalIdentifier: '88b7a0a5-f2d2-5dce-89a6-e0f27577dd3f',
},
{
defaultValue: 'now',
@@ -1397,7 +1397,7 @@ export const EXPECTED_MANIFEST: Manifest = {
label: 'Last update',
name: 'updatedAt',
type: FieldMetadataType.DATE_TIME,
universalIdentifier: 'a81e915c-f21d-5163-a547-f04029655bf0',
universalIdentifier: '411c5a73-100a-5255-a54d-de20a5c2ae54',
},
{
defaultValue: null,
@@ -1407,7 +1407,7 @@ export const EXPECTED_MANIFEST: Manifest = {
label: 'Deleted at',
name: 'deletedAt',
type: FieldMetadataType.DATE_TIME,
universalIdentifier: '33ba5fa8-dbc3-5a08-b0e0-d85b18ff15e0',
universalIdentifier: 'd3d59a1e-2873-594a-a7de-630cde6980a9',
},
{
defaultValue: {
@@ -1420,7 +1420,7 @@ export const EXPECTED_MANIFEST: Manifest = {
label: 'Created by',
name: 'createdBy',
type: FieldMetadataType.ACTOR,
universalIdentifier: '7c377262-63b7-5a79-ba54-f7b8acd804dc',
universalIdentifier: 'f358b82e-1254-581e-8629-4fc4302fdbdf',
},
{
defaultValue: {
@@ -1433,7 +1433,7 @@ export const EXPECTED_MANIFEST: Manifest = {
label: 'Updated by',
name: 'updatedBy',
type: FieldMetadataType.ACTOR,
universalIdentifier: '8da8e149-b60e-52e2-878d-73d05c8312b2',
universalIdentifier: '8186fca8-89da-5542-99fe-f0ddf90e1fc1',
},
{
defaultValue: 0,
@@ -1443,7 +1443,7 @@ export const EXPECTED_MANIFEST: Manifest = {
label: 'Position',
name: 'position',
type: FieldMetadataType.POSITION,
universalIdentifier: '824e930c-ce9f-5267-bebd-f6db215c3c14',
universalIdentifier: '9a81ba2c-e4b2-56f8-a610-015decebd3aa',
},
{
defaultValue: null,
@@ -1453,7 +1453,7 @@ export const EXPECTED_MANIFEST: Manifest = {
label: 'Search vector',
name: 'searchVector',
type: FieldMetadataType.TS_VECTOR,
universalIdentifier: '1f5df24b-8248-59cf-8bb6-362de121d2e4',
universalIdentifier: '41eed58f-1016-5483-b3ff-979f270dd9a6',
},
{
description: 'Recipients tied to the Recipient',
@@ -1462,11 +1462,11 @@ export const EXPECTED_MANIFEST: Manifest = {
label: 'Timeline Activities',
name: 'timelineActivities',
relationTargetFieldMetadataUniversalIdentifier:
'b01f6937-4859-587b-b3df-92e0a6f65e93',
'1ce06ca1-6e9a-53ce-9888-9152b1b2b628',
relationTargetObjectMetadataUniversalIdentifier:
'20202020-6736-4337-b5c4-8b39fae325a5',
type: FieldType.RELATION,
universalIdentifier: '51d6d2d7-2fa8-5f77-b6cf-a96dbb801fa5',
universalIdentifier: '1118dd8a-9781-5254-b625-2d0f05eb46fe',
universalSettings: {
relationType: RelationType.ONE_TO_MANY,
},
@@ -1478,11 +1478,11 @@ export const EXPECTED_MANIFEST: Manifest = {
label: 'Attachments',
name: 'attachments',
relationTargetFieldMetadataUniversalIdentifier:
'9c87e1b8-74a2-5833-9237-281d561d2a7d',
'2081700d-7ce6-5698-a7da-a013feda0eb6',
relationTargetObjectMetadataUniversalIdentifier:
'20202020-bd3d-4c60-8dca-571c71d4447a',
type: FieldType.RELATION,
universalIdentifier: '244a7841-9e45-5ed1-a830-83e73c1f13fa',
universalIdentifier: '15714c1a-d354-5407-8a15-25c117d605f8',
universalSettings: {
relationType: RelationType.ONE_TO_MANY,
},
@@ -1494,11 +1494,11 @@ export const EXPECTED_MANIFEST: Manifest = {
label: 'Note Targets',
name: 'noteTargets',
relationTargetFieldMetadataUniversalIdentifier:
'23508618-7ed8-519e-9fee-a57b94dc98e9',
'a93c479c-6206-5714-94ce-affa6e9886aa',
relationTargetObjectMetadataUniversalIdentifier:
'20202020-fff0-4b44-be82-bda313884400',
type: FieldType.RELATION,
universalIdentifier: 'eab71eb1-b457-555a-8a2b-f292cef1671d',
universalIdentifier: '167f6de7-835e-51e1-b751-34055eef6aa5',
universalSettings: {
relationType: RelationType.ONE_TO_MANY,
},
@@ -1510,11 +1510,11 @@ export const EXPECTED_MANIFEST: Manifest = {
label: 'Task Targets',
name: 'taskTargets',
relationTargetFieldMetadataUniversalIdentifier:
'20500ff3-2410-5bfc-b2e7-804785c07770',
'45752445-c4b6-5e80-8aa1-f5321a838b43',
relationTargetObjectMetadataUniversalIdentifier:
'20202020-5a9a-44e8-95df-771cd06d0fb1',
type: FieldType.RELATION,
universalIdentifier: '305f1890-04ba-5101-b18f-a1ca25a1d8a7',
universalIdentifier: 'd0db1844-4fde-5a5a-b037-ebeabd4467af',
universalSettings: {
relationType: RelationType.ONE_TO_MANY,
},
@@ -1522,7 +1522,7 @@ export const EXPECTED_MANIFEST: Manifest = {
],
icon: 'IconUser',
labelIdentifierFieldMetadataUniversalIdentifier:
'ff07c707-df79-58fa-91a4-c60c31f90e6e',
'c400ccb8-cd91-5ae1-81e3-2df8b6f2cf53',
labelPlural: 'Recipients',
labelSingular: 'Recipient',
namePlural: 'recipients',
@@ -13,6 +13,7 @@ import {
import { assertUnreachable } from 'twenty-shared/utils';
import { v4 } from 'uuid';
import { getApplicationUniversalIdentifierOrThrow } from '@/cli/utilities/application/get-application-universal-identifier-or-throw';
import { CURRENT_EXECUTION_DIRECTORY } from '@/cli/utilities/config/current-execution-directory';
import { convertToLabel } from '@/cli/utilities/entity/entity-label';
import { appendServerVariablesToAppConfig } from '@/cli/utilities/file/append-server-variables.util';
@@ -45,6 +46,13 @@ export class EntityAddCommand {
async execute(entityType?: SyncableEntity, path?: string): Promise<void> {
try {
// Generated files embed identifiers derived from the application
// universal identifier, so scaffolding is blocked until it is defined.
const applicationUniversalIdentifier =
await getApplicationUniversalIdentifierOrThrow(
CURRENT_EXECUTION_DIRECTORY,
);
const entity = entityType ?? (await this.getEntity());
const entityName = this.getFolderName(entity);
@@ -55,7 +63,10 @@ export class EntityAddCommand {
await ensureDir(appPath);
const { name, file } = await this.getEntityData(entity);
const { name, file } = await this.getEntityData(
entity,
applicationUniversalIdentifier,
);
const filePath = join(appPath, this.getFileName(name, entity));
@@ -74,7 +85,11 @@ export class EntityAddCommand {
);
if (entity === SyncableEntity.Object) {
await this.promptAndCreateObjectCompanions(name, path);
await this.promptAndCreateObjectCompanions({
objectName: name,
applicationUniversalIdentifier,
customPath: path,
});
}
if (entity === SyncableEntity.ConnectionProvider) {
@@ -89,7 +104,10 @@ export class EntityAddCommand {
}
}
private async getEntityData(entity: SyncableEntity) {
private async getEntityData(
entity: SyncableEntity,
applicationUniversalIdentifier: string,
) {
switch (entity) {
case SyncableEntity.Object: {
const entityData = await this.getObjectData();
@@ -192,6 +210,7 @@ export class EntityAddCommand {
const file = getViewBaseFile({
name,
applicationUniversalIdentifier,
});
return { name, file };
@@ -259,10 +278,15 @@ export class EntityAddCommand {
}
}
private async promptAndCreateObjectCompanions(
objectName: string,
customPath?: string,
): Promise<void> {
private async promptAndCreateObjectCompanions({
objectName,
applicationUniversalIdentifier,
customPath,
}: {
objectName: string;
applicationUniversalIdentifier: string;
customPath?: string;
}): Promise<void> {
const { createCompanions } = await inquirer.prompt<{
createCompanions: boolean;
}>([
@@ -286,6 +310,7 @@ export class EntityAddCommand {
name: `all-${kebabCase(objectName)}`,
universalIdentifier: viewUniversalIdentifier,
objectUniversalIdentifier: this.lastObjectUniversalIdentifier,
applicationUniversalIdentifier,
fields: this.lastNameFieldUniversalIdentifier
? [
{
@@ -335,6 +360,7 @@ export class EntityAddCommand {
name: `${kebabCase(objectName)}-record-page-fields`,
universalIdentifier: fieldsWidgetViewUniversalIdentifier,
objectUniversalIdentifier: this.lastObjectUniversalIdentifier,
applicationUniversalIdentifier,
type: ViewType.FIELDS_WIDGET,
fields: recordPageFieldsViewFields,
});
@@ -0,0 +1,23 @@
import { join } from 'path';
import { pathExists } from '@/cli/utilities/file/fs-utils';
export const APP_CONFIG_CANDIDATE_PATHS = [
'src/application.config.ts',
'src/application-config.ts',
'src/applicationConfig.ts',
];
export const findAppConfigPath = async (
projectRoot: string,
): Promise<string | null> => {
for (const candidate of APP_CONFIG_CANDIDATE_PATHS) {
const absolute = join(projectRoot, candidate);
if (await pathExists(absolute)) {
return absolute;
}
}
return null;
};
@@ -0,0 +1,38 @@
import {
APP_CONFIG_CANDIDATE_PATHS,
findAppConfigPath,
} from '@/cli/utilities/application/find-app-config-path';
import { extractManifestFromFile } from '@/cli/utilities/build/manifest/manifest-extract-config-from-file';
type ApplicationConfigWithUniversalIdentifier = {
universalIdentifier?: string;
};
// Scaffolding derives deterministic identifiers from the application
// universal identifier, so nothing can be generated before defineApplication
// declares one.
export const getApplicationUniversalIdentifierOrThrow = async (
appPath: string,
): Promise<string> => {
const configPath = await findAppConfigPath(appPath);
if (configPath === null) {
throw new Error(
`Could not find the application config file (expected one of: ${APP_CONFIG_CANDIDATE_PATHS.join(', ')}). Create it with defineApplication({ universalIdentifier: ... }) before generating entities.`,
);
}
const { config } =
await extractManifestFromFile<ApplicationConfigWithUniversalIdentifier>({
appPath,
filePath: configPath,
});
if (!config?.universalIdentifier) {
throw new Error(
`defineApplication in ${configPath} must declare a universalIdentifier before generating entities.`,
);
}
return config.universalIdentifier;
};
@@ -14,10 +14,10 @@ import { validateViewFilterOperands } from '@/cli/utilities/build/manifest/utils
import { type ApplicationConfig, type LogicFunctionConfig } from '@/sdk/define';
import { type CommandMenuItemConfig } from '@/sdk/define/command-menu-items/command-menu-item-config';
import { type FrontComponentConfig } from '@/sdk/define/front-component/front-component-config';
import { type IndexConfig } from '@/sdk/define/indexes/index-config';
import { type PostInstallLogicFunctionConfig } from '@/sdk/define/logic-functions/post-install-logic-function-config';
import { type PreInstallLogicFunctionConfig } from '@/sdk/define/logic-functions/pre-install-logic-function-config';
import { type ObjectConfig } from '@/sdk/define/objects/object-config';
import { type IndexConfig } from '@/sdk/define/indexes/index-config';
import { type PageLayoutConfig } from '@/sdk/define/page-layouts/page-layout-config';
import { type PageLayoutTabConfig } from '@/sdk/define/page-layouts/page-layout-tab-config';
import { type RoleConfig } from '@/sdk/define/roles/role-config';
@@ -96,6 +96,7 @@ export const buildManifest = async (
const warnings: string[] = [];
let applicationConfig: ApplicationConfig | undefined;
const objectConfigs: ObjectConfig[] = [];
const objects: ObjectManifest[] = [];
const fields: FieldManifest[] = [];
const indexes: IndexManifest[] = [];
@@ -172,31 +173,7 @@ export const buildManifest = async (
filePath,
});
const {
objectFields: objectFieldsWithDefaults,
fields: reverseRelationFields,
} = getDefaultFieldsInObjectFields(extract.config);
const labelIdentifierFieldMetadataUniversalIdentifier =
extract.config.labelIdentifierFieldMetadataUniversalIdentifier ??
objectFieldsWithDefaults.find((field) => field.name === 'name')
?.universalIdentifier;
if (!labelIdentifierFieldMetadataUniversalIdentifier) {
errors.push(
`No label identifier field found for object ${extract.config.nameSingular}. Please add a field with name "name" to your object.`,
);
break;
}
const objectManifest: ObjectManifest = {
...extract.config,
fields: objectFieldsWithDefaults.map(addMissingFieldOptionIds),
labelIdentifierFieldMetadataUniversalIdentifier,
};
objects.push(objectManifest);
fields.push(...reverseRelationFields);
objectConfigs.push(extract.config);
errors.push(...extract.errors);
warnings.push(...(extract.warnings ?? []));
@@ -522,6 +499,39 @@ export const buildManifest = async (
);
}
if (applicationConfig) {
for (const objectConfig of objectConfigs) {
const {
objectFields: objectFieldsWithDefaults,
fields: reverseRelationFields,
} = getDefaultFieldsInObjectFields({
objectConfig,
applicationUniversalIdentifier: applicationConfig.universalIdentifier,
});
const labelIdentifierFieldMetadataUniversalIdentifier =
objectConfig.labelIdentifierFieldMetadataUniversalIdentifier ??
objectFieldsWithDefaults.find((field) => field.name === 'name')
?.universalIdentifier;
if (!labelIdentifierFieldMetadataUniversalIdentifier) {
errors.push(
`No label identifier field found for object ${objectConfig.nameSingular}. Please add a field with name "name" to your object.`,
);
continue;
}
const objectManifest: ObjectManifest = {
...objectConfig,
fields: objectFieldsWithDefaults.map(addMissingFieldOptionIds),
labelIdentifierFieldMetadataUniversalIdentifier,
};
objects.push(objectManifest);
fields.push(...reverseRelationFields);
}
}
if (postInstallLogicFunctions.length > 1) {
errors.push(
'Only one post install logic function is allowed per application',
@@ -14,15 +14,32 @@ const baseObjectConfig: ObjectConfig = {
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 } =
getDefaultFieldsInObjectFields(baseObjectConfig);
const defaultFields = getDefaultObjectFields(baseObjectConfig);
getMockDefaultFieldsInObjectFields(baseObjectConfig);
const defaultFields = getMockDefaultObjectFields(baseObjectConfig);
const {
objectFields: defaultRelationObjectFields,
fields: expectedReverseFields,
} = getDefaultRelationObjectFields(baseObjectConfig);
} = getMockDefaultRelationObjectFields(baseObjectConfig);
expect(objectFields).toEqual([
...defaultFields,
@@ -56,10 +73,10 @@ describe('getDefaultFieldsInObjectFields', () => {
fields: [customField],
};
const { objectFields } = getDefaultFieldsInObjectFields(objectConfig);
const defaultFields = getDefaultObjectFields(objectConfig);
const { objectFields } = getMockDefaultFieldsInObjectFields(objectConfig);
const defaultFields = getMockDefaultObjectFields(objectConfig);
const { objectFields: defaultRelationObjectFields } =
getDefaultRelationObjectFields(objectConfig);
getMockDefaultRelationObjectFields(objectConfig);
expect(objectFields[0]).toEqual(customField);
expect(objectFields).toHaveLength(
@@ -80,7 +97,7 @@ describe('getDefaultFieldsInObjectFields', () => {
fields: [customIdField],
};
const { objectFields } = getDefaultFieldsInObjectFields(objectConfig);
const { objectFields } = getMockDefaultFieldsInObjectFields(objectConfig);
const idFields = objectFields.filter((f) => f.name === 'id');
@@ -115,10 +132,10 @@ describe('getDefaultFieldsInObjectFields', () => {
fields: customFields,
};
const { objectFields } = getDefaultFieldsInObjectFields(objectConfig);
const defaultFields = getDefaultObjectFields(objectConfig);
const { objectFields } = getMockDefaultFieldsInObjectFields(objectConfig);
const defaultFields = getMockDefaultObjectFields(objectConfig);
const { objectFields: defaultRelationObjectFields } =
getDefaultRelationObjectFields(objectConfig);
getMockDefaultRelationObjectFields(objectConfig);
const overriddenCount = defaultFields.filter((df) =>
customFields.some((cf) => cf.name === df.name),
).length;
@@ -148,7 +165,7 @@ describe('getDefaultFieldsInObjectFields', () => {
fields: [customField],
};
const { objectFields } = getDefaultFieldsInObjectFields(objectConfig);
const { objectFields } = getMockDefaultFieldsInObjectFields(objectConfig);
expect(objectFields[0]).toEqual(customField);
});
@@ -168,13 +185,13 @@ describe('getDefaultFieldsInObjectFields', () => {
const originalLength = objectConfig.fields.length;
getDefaultFieldsInObjectFields(objectConfig);
getMockDefaultFieldsInObjectFields(objectConfig);
expect(objectConfig.fields).toHaveLength(originalLength);
});
it('should return reverse relation fields for each default relation', () => {
const { fields } = getDefaultFieldsInObjectFields(baseObjectConfig);
const { fields } = getMockDefaultFieldsInObjectFields(baseObjectConfig);
expect(fields).toHaveLength(4);
@@ -12,21 +12,31 @@ const mockObjectConfig: ObjectConfig = {
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 = getDefaultObjectFields(mockObjectConfig);
const fields = getMockDefaultObjectFields(mockObjectConfig);
expect(fields).toHaveLength(9);
});
it('should include an id field with UUID type', () => {
const fields = getDefaultObjectFields(mockObjectConfig);
const fields = getMockDefaultObjectFields(mockObjectConfig);
const idField = fields.find((field) => field.name === 'id');
expect(idField).toBeDefined();
@@ -43,7 +53,7 @@ describe('getDefaultObjectFields', () => {
});
it('should include a name field with TEXT type', () => {
const fields = getDefaultObjectFields(mockObjectConfig);
const fields = getMockDefaultObjectFields(mockObjectConfig);
const nameField = fields.find((field) => field.name === 'name');
expect(nameField).toBeDefined();
@@ -60,7 +70,7 @@ describe('getDefaultObjectFields', () => {
});
it('should include createdAt, updatedAt and deletedAt fields with DATE_TIME type', () => {
const fields = getDefaultObjectFields(mockObjectConfig);
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');
@@ -103,7 +113,7 @@ describe('getDefaultObjectFields', () => {
});
it('should include createdBy and updatedBy fields with ACTOR type', () => {
const fields = getDefaultObjectFields(mockObjectConfig);
const fields = getMockDefaultObjectFields(mockObjectConfig);
const createdByField = fields.find((field) => field.name === 'createdBy');
const updatedByField = fields.find((field) => field.name === 'updatedBy');
@@ -133,7 +143,7 @@ describe('getDefaultObjectFields', () => {
});
it('should include a position field with POSITION type', () => {
const fields = getDefaultObjectFields(mockObjectConfig);
const fields = getMockDefaultObjectFields(mockObjectConfig);
const positionField = fields.find((field) => field.name === 'position');
expect(positionField).toBeDefined();
@@ -150,7 +160,7 @@ describe('getDefaultObjectFields', () => {
});
it('should include a searchVector field with TS_VECTOR type', () => {
const fields = getDefaultObjectFields(mockObjectConfig);
const fields = getMockDefaultObjectFields(mockObjectConfig);
const searchVectorField = fields.find(
(field) => field.name === 'searchVector',
);
@@ -169,8 +179,8 @@ describe('getDefaultObjectFields', () => {
});
it('should generate deterministic universalIdentifiers based on objectConfig', () => {
const firstResult = getDefaultObjectFields(mockObjectConfig);
const secondResult = getDefaultObjectFields(mockObjectConfig);
const firstResult = getMockDefaultObjectFields(mockObjectConfig);
const secondResult = getMockDefaultObjectFields(mockObjectConfig);
firstResult.forEach((field, index) => {
expect(field.universalIdentifier).toBe(
@@ -185,8 +195,8 @@ describe('getDefaultObjectFields', () => {
universalIdentifier: 'ffffffff-ffff-ffff-ffff-ffffffffffff',
};
const fieldsA = getDefaultObjectFields(mockObjectConfig);
const fieldsB = getDefaultObjectFields(otherObjectConfig);
const fieldsA = getMockDefaultObjectFields(mockObjectConfig);
const fieldsB = getMockDefaultObjectFields(otherObjectConfig);
fieldsA.forEach((fieldA, index) => {
expect(fieldA.universalIdentifier).not.toBe(
@@ -196,7 +206,7 @@ describe('getDefaultObjectFields', () => {
});
it('should return fields in the expected order', () => {
const fields = getDefaultObjectFields(mockObjectConfig);
const fields = getMockDefaultObjectFields(mockObjectConfig);
const fieldNames = fields.map((field) => field.name);
expect(fieldNames).toEqual([
@@ -6,12 +6,22 @@ import {
type ObjectFieldManifest,
} from 'twenty-shared/application';
export const getDefaultFieldsInObjectFields = (
objectConfig: ObjectConfig,
): { objectFields: ObjectFieldManifest[]; fields: FieldManifest[] } => {
const defaultObjectFields = getDefaultObjectFields(objectConfig);
export const getDefaultFieldsInObjectFields = ({
objectConfig,
applicationUniversalIdentifier,
}: {
objectConfig: ObjectConfig;
applicationUniversalIdentifier: string;
}): { objectFields: ObjectFieldManifest[]; fields: FieldManifest[] } => {
const defaultObjectFields = getDefaultObjectFields({
objectConfig,
applicationUniversalIdentifier,
});
const { objectFields: defaultRelationObjectFields, fields: reverseFields } =
getDefaultRelationObjectFields(objectConfig);
getDefaultRelationObjectFields({
objectConfig,
applicationUniversalIdentifier,
});
const objectConfigFieldNames = (objectConfig.fields ?? []).map((f) => f.name);
@@ -3,9 +3,13 @@ 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: ObjectConfig,
): ObjectFieldManifest[] => {
export const getDefaultObjectFields = ({
objectConfig,
applicationUniversalIdentifier,
}: {
objectConfig: ObjectConfig;
applicationUniversalIdentifier: string;
}): ObjectFieldManifest[] => {
const objectUniversalIdentifier = objectConfig.universalIdentifier;
const idField: ObjectFieldManifest = {
@@ -17,6 +21,7 @@ export const getDefaultObjectFields = (
defaultValue: 'uuid',
type: FieldMetadataType.UUID as const,
universalIdentifier: generateDefaultFieldUniversalIdentifier({
applicationUniversalIdentifier,
objectUniversalIdentifier,
fieldName: 'id',
}),
@@ -31,6 +36,7 @@ export const getDefaultObjectFields = (
defaultValue: null,
type: FieldMetadataType.TEXT as const,
universalIdentifier: generateDefaultFieldUniversalIdentifier({
applicationUniversalIdentifier,
objectUniversalIdentifier,
fieldName: 'name',
}),
@@ -45,6 +51,7 @@ export const getDefaultObjectFields = (
defaultValue: 'now',
type: FieldMetadataType.DATE_TIME as const,
universalIdentifier: generateDefaultFieldUniversalIdentifier({
applicationUniversalIdentifier,
objectUniversalIdentifier,
fieldName: 'createdAt',
}),
@@ -59,6 +66,7 @@ export const getDefaultObjectFields = (
defaultValue: 'now',
type: FieldMetadataType.DATE_TIME as const,
universalIdentifier: generateDefaultFieldUniversalIdentifier({
applicationUniversalIdentifier,
objectUniversalIdentifier,
fieldName: 'updatedAt',
}),
@@ -73,6 +81,7 @@ export const getDefaultObjectFields = (
defaultValue: null,
type: FieldMetadataType.DATE_TIME as const,
universalIdentifier: generateDefaultFieldUniversalIdentifier({
applicationUniversalIdentifier,
objectUniversalIdentifier,
fieldName: 'deletedAt',
}),
@@ -87,6 +96,7 @@ export const getDefaultObjectFields = (
defaultValue: { name: "''", source: "'MANUAL'" },
type: FieldMetadataType.ACTOR as const,
universalIdentifier: generateDefaultFieldUniversalIdentifier({
applicationUniversalIdentifier,
objectUniversalIdentifier,
fieldName: 'createdBy',
}),
@@ -101,6 +111,7 @@ export const getDefaultObjectFields = (
defaultValue: { name: "''", source: "'MANUAL'" },
type: FieldMetadataType.ACTOR as const,
universalIdentifier: generateDefaultFieldUniversalIdentifier({
applicationUniversalIdentifier,
objectUniversalIdentifier,
fieldName: 'updatedBy',
}),
@@ -115,6 +126,7 @@ export const getDefaultObjectFields = (
defaultValue: 0,
type: FieldMetadataType.POSITION,
universalIdentifier: generateDefaultFieldUniversalIdentifier({
applicationUniversalIdentifier,
objectUniversalIdentifier,
fieldName: 'position',
}),
@@ -129,6 +141,7 @@ export const getDefaultObjectFields = (
defaultValue: null,
type: FieldMetadataType.TS_VECTOR,
universalIdentifier: generateDefaultFieldUniversalIdentifier({
applicationUniversalIdentifier,
objectUniversalIdentifier,
fieldName: 'searchVector',
}),
@@ -1,5 +1,5 @@
import { generateDefaultFieldUniversalIdentifier } from '@/sdk/define/objects/generate-default-field-universal-identifier';
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,
@@ -119,9 +119,13 @@ const buildReverseField = ({
};
};
export const getDefaultRelationObjectFields = (
objectConfig: ObjectConfig,
): { objectFields: ObjectFieldManifest[]; fields: FieldManifest[] } => {
export const getDefaultRelationObjectFields = ({
objectConfig,
applicationUniversalIdentifier,
}: {
objectConfig: ObjectConfig;
applicationUniversalIdentifier: string;
}): { objectFields: ObjectFieldManifest[]; fields: FieldManifest[] } => {
const objectFields: ObjectFieldManifest[] = [];
const fields: FieldManifest[] = [];
@@ -130,14 +134,16 @@ export const getDefaultRelationObjectFields = (
const forwardFieldUniversalIdentifier =
generateDefaultFieldUniversalIdentifier({
applicationUniversalIdentifier,
objectUniversalIdentifier: objectConfig.universalIdentifier,
fieldName: config.fieldName,
});
const reverseFieldUniversalIdentifier =
generateDefaultFieldUniversalIdentifier({
objectUniversalIdentifier: objectConfig.universalIdentifier,
fieldName: `${config.fieldName}Inverse`,
applicationUniversalIdentifier,
objectUniversalIdentifier: standardObject.universalIdentifier,
fieldName: config.targetFieldName(objectConfig),
});
const forwardField: ObjectFieldManifest = {
@@ -1,8 +1,21 @@
import { getViewBaseFile } from '@/cli/utilities/entity/entity-view-template';
const APPLICATION_UNIVERSAL_IDENTIFIER = 'app-abc-123';
const getTestViewBaseFile = (
overrides: Omit<
Parameters<typeof getViewBaseFile>[0],
'applicationUniversalIdentifier'
>,
) =>
getViewBaseFile({
applicationUniversalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER,
...overrides,
});
describe('getViewBaseFile', () => {
it('should render proper file using defineView', () => {
const result = getViewBaseFile({
const result = getTestViewBaseFile({
name: 'my-view',
universalIdentifier: '71e45a58-41da-4ae4-8b73-a543c0a9d3d4',
objectUniversalIdentifier: 'obj-abc-123',
@@ -20,7 +33,7 @@ describe('getViewBaseFile', () => {
});
it('should use default objectUniversalIdentifier when not provided', () => {
const result = getViewBaseFile({
const result = getTestViewBaseFile({
name: 'default-view',
});
@@ -28,7 +41,7 @@ describe('getViewBaseFile', () => {
});
it('should include commented fields, filters and sorts when no fields provided', () => {
const result = getViewBaseFile({
const result = getTestViewBaseFile({
name: 'empty-view',
});
@@ -38,7 +51,7 @@ describe('getViewBaseFile', () => {
});
it('should render fields block when fields are provided', () => {
const result = getViewBaseFile({
const result = getTestViewBaseFile({
name: 'view-with-fields',
fields: [
{
@@ -61,7 +74,7 @@ describe('getViewBaseFile', () => {
});
it('should apply default values to fields', () => {
const result = getViewBaseFile({
const result = getTestViewBaseFile({
name: 'view-defaults',
fields: [
{
@@ -77,7 +90,7 @@ describe('getViewBaseFile', () => {
});
it('should generate unique UUID when not provided', () => {
const result = getViewBaseFile({
const result = getTestViewBaseFile({
name: 'auto-uuid-view',
});
@@ -87,7 +100,7 @@ describe('getViewBaseFile', () => {
});
it('should use kebab-case for name', () => {
const result = getViewBaseFile({
const result = getTestViewBaseFile({
name: 'all active contacts',
});
@@ -95,7 +108,7 @@ describe('getViewBaseFile', () => {
});
it('should generate UUIDs for fields when not provided', () => {
const result = getViewBaseFile({
const result = getTestViewBaseFile({
name: 'view-auto-field-uuids',
fields: [
{
@@ -115,7 +128,7 @@ describe('getViewBaseFile', () => {
});
it('should emit a generateDefaultFieldUniversalIdentifier call for fields using defaultFieldName', () => {
const result = getViewBaseFile({
const result = getTestViewBaseFile({
name: 'view-default-field',
objectUniversalIdentifier: 'obj-abc-123',
fields: [
@@ -132,12 +145,13 @@ describe('getViewBaseFile', () => {
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', () => {
const result = getViewBaseFile({
const result = getTestViewBaseFile({
name: 'view-literal-only',
fields: [
{
@@ -17,10 +17,12 @@ const renderFieldEntry = ({
field,
index,
objectUniversalIdentifier,
applicationUniversalIdentifier,
}: {
field: ViewFieldTemplate;
index: number;
objectUniversalIdentifier: string;
applicationUniversalIdentifier: string;
}) => {
const universalIdentifier = field.universalIdentifier ?? v4();
const position = field.position ?? index;
@@ -30,6 +32,7 @@ const renderFieldEntry = ({
const fieldMetadataUniversalIdentifierLine =
'defaultFieldName' in field
? ` fieldMetadataUniversalIdentifier: generateDefaultFieldUniversalIdentifier({
applicationUniversalIdentifier: '${applicationUniversalIdentifier}',
objectUniversalIdentifier: '${objectUniversalIdentifier}',
fieldName: '${field.defaultFieldName}',
})`
@@ -48,12 +51,14 @@ export const getViewBaseFile = ({
name,
universalIdentifier = v4(),
objectUniversalIdentifier = 'fill-later',
applicationUniversalIdentifier,
fields = [],
type,
}: {
name: string;
universalIdentifier?: string;
objectUniversalIdentifier?: string;
applicationUniversalIdentifier: string;
fields?: ViewFieldTemplate[];
type?: ViewType;
}) => {
@@ -77,7 +82,12 @@ export const getViewBaseFile = ({
? ` fields: [
${fields
.map((field, index) =>
renderFieldEntry({ field, index, objectUniversalIdentifier }),
renderFieldEntry({
field,
index,
objectUniversalIdentifier,
applicationUniversalIdentifier,
}),
)
.join(',\n')},
],`
@@ -1,6 +1,6 @@
import { readFile, writeFile } from 'node:fs/promises';
import { pathExists } from '@/cli/utilities/file/fs-utils';
import { findAppConfigPath } from '@/cli/utilities/application/find-app-config-path';
// One name + description pair to add to defineApplication.serverVariables.
// `isSecret` defaults to false; set true on credentials that should be
@@ -11,12 +11,6 @@ export type ServerVariableSpec = {
isSecret: boolean;
};
const APP_CONFIG_CANDIDATES = [
'src/application.config.ts',
'src/application-config.ts',
'src/applicationConfig.ts',
];
const SERVER_VARIABLES_PATTERN = /serverVariables\s*:\s*\{/;
const DEFINE_APPLICATION_PATTERN = /defineApplication\s*\(\s*\{/;
@@ -102,20 +96,6 @@ export const appendServerVariablesToAppConfig = async ({
return { status: 'created', file: configPath };
};
const findAppConfigPath = async (
projectRoot: string,
): Promise<string | null> => {
for (const candidate of APP_CONFIG_CANDIDATES) {
const absolute = `${projectRoot}/${candidate}`;
if (await pathExists(absolute)) {
return absolute;
}
}
return null;
};
const renderServerVariableEntries = (variables: ServerVariableSpec[]): string =>
variables
.map(
@@ -1,19 +1,25 @@
import { generateDefaultFieldUniversalIdentifier } from '@/sdk/define/objects/generate-default-field-universal-identifier';
describe('generateDefaultFieldUniversalIdentifier', () => {
it('should generate a unique universal identifier', () => {
const objectUniversalIdentifier = '55b79f88-4094-4b3f-a0ac-1a91a55714f2';
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',
});
@@ -21,4 +27,36 @@ describe('generateDefaultFieldUniversalIdentifier', () => {
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',
}),
);
});
});
@@ -1,15 +1,16 @@
import { v5 } from 'uuid';
const UNIVERSAL_IDENTIFIER_NAMESPACE = '142046f0-4d80-48b5-ad56-26ad410e895c';
import { getFieldUniversalIdentifier } from 'twenty-shared/application';
export const generateDefaultFieldUniversalIdentifier = ({
applicationUniversalIdentifier,
objectUniversalIdentifier,
fieldName,
}: {
applicationUniversalIdentifier: string;
objectUniversalIdentifier: string;
fieldName: string;
}) => {
const seed = `${objectUniversalIdentifier}-${fieldName}`;
return v5(seed, UNIVERSAL_IDENTIFIER_NAMESPACE);
};
}) =>
getFieldUniversalIdentifier({
applicationUniversalIdentifier,
objectUniversalIdentifier,
name: fieldName,
});
@@ -4,13 +4,16 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { WorkspaceIteratorModule } from 'src/database/commands/command-runners/workspace-iterator.module';
import { BackfillWorkspaceCustomApplicationRegistrationCommand } from 'src/database/commands/upgrade-version-command/2-19/2-19-workspace-command-1782853718000-backfill-workspace-custom-application-registration.command';
import { BackfillSystemUniqueIndexUniversalIdentifierCommand } from 'src/database/commands/upgrade-version-command/2-19/2-19-workspace-command-1783093620000-backfill-system-unique-index-universal-identifier.command';
import { BackfillDeterministicFieldUniversalIdentifiersCommand } from 'src/database/commands/upgrade-version-command/2-19/2-19-workspace-command-1783100000000-backfill-deterministic-field-universal-identifiers.command';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { IndexMetadataEntity } from 'src/engine/metadata-modules/index-metadata/index-metadata.entity';
import { WorkspaceMetadataVersionModule } from 'src/engine/metadata-modules/workspace-metadata-version/workspace-metadata-version.module';
import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module';
import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace-migration/workspace-migration.module';
import { WorkspaceMigrationRunnerModule } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/workspace-migration-runner.module';
@Module({
imports: [
@@ -18,16 +21,19 @@ import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace
TypeOrmModule.forFeature([
WorkspaceEntity,
ApplicationEntity,
FieldMetadataEntity,
IndexMetadataEntity,
]),
WorkspaceIteratorModule,
WorkspaceCacheModule,
WorkspaceMetadataVersionModule,
WorkspaceMigrationModule,
WorkspaceMigrationRunnerModule,
],
providers: [
BackfillWorkspaceCustomApplicationRegistrationCommand,
BackfillSystemUniqueIndexUniversalIdentifierCommand,
BackfillDeterministicFieldUniversalIdentifiersCommand,
],
})
export class V2_19_UpgradeVersionCommandModule {}
@@ -0,0 +1,368 @@
import { InjectRepository } from '@nestjs/typeorm';
import { Command } from 'nest-commander';
import { getFieldUniversalIdentifier } from 'twenty-shared/application';
import { STANDARD_OBJECTS } from 'twenty-shared/metadata';
import { FieldMetadataType } from 'twenty-shared/types';
import { capitalize, isDefined } from 'twenty-shared/utils';
import { Repository } from 'typeorm';
import { v5 } from 'uuid';
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 { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { RegisteredWorkspaceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-workspace-command.decorator';
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
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 { 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 { 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 { PARTIAL_SYSTEM_FLAT_FIELD_METADATAS } from 'src/engine/metadata-modules/object-metadata/constants/partial-system-flat-field-metadatas.constant';
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
import { WorkspaceMigrationRunnerService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/services/workspace-migration-runner.service';
// Server-owned system field names: their universal identifiers are taken
// over for every application, whatever value they currently hold, since
// validateObjectMetadataSystemFieldsIntegrity now rejects any non-derived
// value at sync time. Fields are picked explicitly so system fields added to
// PARTIAL_SYSTEM_FLAT_FIELD_METADATAS later never alter this shipped
// migration.
const SYSTEM_FIELD_NAMES = new Set<string>([
PARTIAL_SYSTEM_FLAT_FIELD_METADATAS.id.name,
PARTIAL_SYSTEM_FLAT_FIELD_METADATAS.createdAt.name,
PARTIAL_SYSTEM_FLAT_FIELD_METADATAS.updatedAt.name,
PARTIAL_SYSTEM_FLAT_FIELD_METADATAS.deletedAt.name,
PARTIAL_SYSTEM_FLAT_FIELD_METADATAS.createdBy.name,
PARTIAL_SYSTEM_FLAT_FIELD_METADATAS.updatedBy.name,
PARTIAL_SYSTEM_FLAT_FIELD_METADATAS.position.name,
PARTIAL_SYSTEM_FLAT_FIELD_METADATAS.searchVector.name,
]);
// The name field is a default field, not a system field: it is only taken
// over where it is guaranteed to be auto-provisioned (workspace-custom
// objects). For installed applications authors may define it themselves, so
// only legacy SDK-derived values are converged; for the standard application
// it is author-provided in STANDARD_OBJECTS and keeps its hardcoded value.
const NAME_FIELD_NAME = 'name';
const DEFAULT_RELATION_FORWARD_FIELD_NAMES = new Set([
'timelineActivities',
'attachments',
'noteTargets',
'taskTargets',
]);
const DEFAULT_RELATION_STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS = new Set<string>([
STANDARD_OBJECTS.timelineActivity.universalIdentifier,
STANDARD_OBJECTS.attachment.universalIdentifier,
STANDARD_OBJECTS.noteTarget.universalIdentifier,
STANDARD_OBJECTS.taskTarget.universalIdentifier,
]);
// Namespace the SDK used before default field universal identifiers were
// aligned with getFieldUniversalIdentifier; only rows still carrying a
// legacy-derived value are backfilled for installed applications, so
// author-provided identifiers are left untouched.
const LEGACY_SDK_UNIVERSAL_IDENTIFIER_NAMESPACE =
'142046f0-4d80-48b5-ad56-26ad410e895c';
const computeLegacySdkDefaultFieldUniversalIdentifier = ({
objectUniversalIdentifier,
fieldName,
}: {
objectUniversalIdentifier: string;
fieldName: string;
}) =>
v5(
`${objectUniversalIdentifier}-${fieldName}`,
LEGACY_SDK_UNIVERSAL_IDENTIFIER_NAMESPACE,
);
const isMorphOrRelationFieldMetadataType = (type: FieldMetadataType) =>
type === FieldMetadataType.RELATION ||
type === FieldMetadataType.MORPH_RELATION;
@RegisteredWorkspaceCommand('2.19.0', 1783100000000)
@Command({
name: 'upgrade:2-19:backfill-deterministic-field-universal-identifiers',
description:
'Recompute the universal identifier of auto-provisioned field metadata (system fields and default relation fields) to the deterministic getFieldUniversalIdentifier derivation.',
})
export class BackfillDeterministicFieldUniversalIdentifiersCommand extends ActiveOrSuspendedWorkspaceCommandRunner {
constructor(
protected readonly workspaceIteratorService: WorkspaceIteratorService,
private readonly applicationService: ApplicationService,
private readonly workspaceCacheService: WorkspaceCacheService,
private readonly workspaceMigrationRunnerService: WorkspaceMigrationRunnerService,
@InjectRepository(ApplicationEntity)
private readonly applicationRepository: Repository<ApplicationEntity>,
@InjectRepository(FieldMetadataEntity)
private readonly fieldMetadataRepository: Repository<FieldMetadataEntity>,
) {
super(workspaceIteratorService);
}
override async runOnWorkspace({
workspaceId,
options,
}: RunOnWorkspaceArgs): Promise<void> {
const isDryRun = options.dryRun ?? false;
const { twentyStandardFlatApplication, workspaceCustomFlatApplication } =
await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow(
{ workspaceId },
);
const applications = await this.applicationRepository.find({
select: ['id', 'universalIdentifier'],
where: { workspaceId },
withDeleted: true,
});
const applicationUniversalIdentifierById = new Map(
applications.map((application) => [
application.id,
application.universalIdentifier,
]),
);
const { flatFieldMetadataMaps, flatObjectMetadataMaps } =
await this.workspaceCacheService.getOrRecompute(workspaceId, [
'flatFieldMetadataMaps',
'flatObjectMetadataMaps',
]);
const updates: { id: string; newUniversalIdentifier: string }[] = [];
for (const flatFieldMetadata of Object.values(
flatFieldMetadataMaps.byUniversalIdentifier,
)) {
if (!isDefined(flatFieldMetadata)) {
continue;
}
const flatObjectMetadata = findFlatEntityByIdInFlatEntityMaps({
flatEntityMaps: flatObjectMetadataMaps,
flatEntityId: flatFieldMetadata.objectMetadataId,
});
const applicationUniversalIdentifier =
applicationUniversalIdentifierById.get(flatFieldMetadata.applicationId);
if (
!isDefined(flatObjectMetadata) ||
!isDefined(applicationUniversalIdentifier)
) {
this.logger.warn(
`Missing object or application for field ${flatFieldMetadata.name} (${flatFieldMetadata.id}) in workspace ${workspaceId}, skipping`,
);
continue;
}
const shouldBackfill = this.shouldBackfillFieldMetadata({
flatFieldMetadata,
flatObjectMetadata,
flatObjectMetadataMaps,
flatFieldMetadataMaps,
twentyStandardApplicationId: twentyStandardFlatApplication.id,
workspaceCustomApplicationId: workspaceCustomFlatApplication.id,
});
if (!shouldBackfill) {
continue;
}
const newUniversalIdentifier = getFieldUniversalIdentifier({
applicationUniversalIdentifier,
objectUniversalIdentifier: flatObjectMetadata.universalIdentifier,
name: flatFieldMetadata.name,
});
if (newUniversalIdentifier === flatFieldMetadata.universalIdentifier) {
continue;
}
updates.push({ id: flatFieldMetadata.id, newUniversalIdentifier });
}
if (updates.length === 0) {
this.logger.log(
`No field universal identifiers to backfill for workspace ${workspaceId}`,
);
return;
}
this.logger.log(
`${isDryRun ? '[DRY RUN] ' : ''}Backfilling ${updates.length} field universal identifier(s) for workspace ${workspaceId}`,
);
if (isDryRun) {
return;
}
await this.fieldMetadataRepository.manager.transaction(
async (entityManager) => {
const transactionalFieldMetadataRepository =
entityManager.getRepository(FieldMetadataEntity);
for (const { id, newUniversalIdentifier } of updates) {
await transactionalFieldMetadataRepository.update(
{ id, workspaceId },
{ universalIdentifier: newUniversalIdentifier },
);
}
},
);
const fieldMetadataRelatedNames = [
'fieldMetadata',
...getMetadataRelatedMetadataNames('fieldMetadata'),
...getMetadataSerializedRelationNames('fieldMetadata'),
'index',
] as const;
const allFlatEntityMapsKeys = [
...new Set(fieldMetadataRelatedNames.map(getMetadataFlatEntityMapsKey)),
];
await this.workspaceMigrationRunnerService.invalidateCache({
allFlatEntityMapsKeys,
workspaceId,
});
this.logger.log(
`Backfilled ${updates.length} field universal identifier(s) for workspace ${workspaceId}`,
);
}
private shouldBackfillFieldMetadata({
flatFieldMetadata,
flatObjectMetadata,
flatObjectMetadataMaps,
flatFieldMetadataMaps,
twentyStandardApplicationId,
workspaceCustomApplicationId,
}: {
flatFieldMetadata: FlatFieldMetadata;
flatObjectMetadata: FlatObjectMetadata;
flatObjectMetadataMaps: FlatEntityMaps<FlatObjectMetadata>;
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>;
twentyStandardApplicationId: string;
workspaceCustomApplicationId: string;
}): boolean {
// System field universal identifiers are server-owned: they are taken
// over for every application. Sync now rejects non-derived values
// (validateObjectMetadataSystemFieldsIntegrity), so converging every row
// here is both safe and required.
if (SYSTEM_FIELD_NAMES.has(flatFieldMetadata.name)) {
return true;
}
// Standard application: every non-system standard field (including name)
// is author-provided in STANDARD_OBJECTS and keeps its hardcoded value.
if (flatFieldMetadata.applicationId === twentyStandardApplicationId) {
return false;
}
const relationTargetFlatObjectMetadata = isDefined(
flatFieldMetadata.relationTargetObjectMetadataId,
)
? findFlatEntityByIdInFlatEntityMaps({
flatEntityMaps: flatObjectMetadataMaps,
flatEntityId: flatFieldMetadata.relationTargetObjectMetadataId,
})
: undefined;
if (flatFieldMetadata.applicationId === workspaceCustomApplicationId) {
if (flatFieldMetadata.name === NAME_FIELD_NAME) {
return true;
}
if (!isMorphOrRelationFieldMetadataType(flatFieldMetadata.type)) {
return false;
}
// Forward default relation fields on custom objects (attachments,
// noteTargets, taskTargets, timelineActivities).
if (
DEFAULT_RELATION_FORWARD_FIELD_NAMES.has(flatFieldMetadata.name) &&
isDefined(relationTargetFlatObjectMetadata) &&
DEFAULT_RELATION_STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.has(
relationTargetFlatObjectMetadata.universalIdentifier,
)
) {
return true;
}
// Reverse default relation fields living on the standard relation
// objects and pointing back at the custom object.
if (
DEFAULT_RELATION_STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.has(
flatObjectMetadata.universalIdentifier,
) &&
isDefined(relationTargetFlatObjectMetadata) &&
(flatFieldMetadata.name ===
`target${capitalize(relationTargetFlatObjectMetadata.nameSingular)}` ||
flatFieldMetadata.name ===
relationTargetFlatObjectMetadata.nameSingular)
) {
return true;
}
return false;
}
// Installed applications, remaining auto-provisioned fields (injected
// name field and default relation fields): only rows still carrying an
// SDK-auto-generated (legacy derivation) identifier are backfilled;
// author-provided identifiers must keep matching the application source
// code.
const legacyDefaultUniversalIdentifier =
computeLegacySdkDefaultFieldUniversalIdentifier({
objectUniversalIdentifier: flatObjectMetadata.universalIdentifier,
fieldName: flatFieldMetadata.name,
});
if (
flatFieldMetadata.universalIdentifier === legacyDefaultUniversalIdentifier
) {
return true;
}
// Legacy reverse default relation fields were derived from the custom
// object universal identifier and the forward field name suffixed with
// "Inverse".
const relationTargetFlatFieldMetadata = isDefined(
flatFieldMetadata.relationTargetFieldMetadataId,
)
? findFlatEntityByIdInFlatEntityMaps({
flatEntityMaps: flatFieldMetadataMaps,
flatEntityId: flatFieldMetadata.relationTargetFieldMetadataId,
})
: undefined;
if (
isDefined(relationTargetFlatObjectMetadata) &&
isDefined(relationTargetFlatFieldMetadata)
) {
const legacyReverseUniversalIdentifier =
computeLegacySdkDefaultFieldUniversalIdentifier({
objectUniversalIdentifier:
relationTargetFlatObjectMetadata.universalIdentifier,
fieldName: `${relationTargetFlatFieldMetadata.name}Inverse`,
});
if (
flatFieldMetadata.universalIdentifier ===
legacyReverseUniversalIdentifier
) {
return true;
}
}
return false;
}
}
@@ -33,6 +33,7 @@ import { MessageChannelSyncStatusService } from 'src/modules/messaging/common/se
import { SyncMessageFoldersService } from 'src/modules/messaging/message-folder-manager/services/sync-message-folders.service';
jest.mock('uuid', () => ({
...jest.requireActual('uuid'),
v4: jest.fn(() => 'mocked-uuid'),
}));
@@ -30,6 +30,7 @@ import { MessageChannelSyncStatusService } from 'src/modules/messaging/common/se
import { SyncMessageFoldersService } from 'src/modules/messaging/message-folder-manager/services/sync-message-folders.service';
jest.mock('uuid', () => ({
...jest.requireActual('uuid'),
v4: jest.fn(() => 'mocked-uuid'),
}));
@@ -22,6 +22,7 @@ import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/
import { getWorkspaceScopedRepositoryToken } from 'src/engine/twenty-orm/workspace-scoped-repository/get-workspace-scoped-repository-token.util';
jest.mock('uuid', () => ({
...jest.requireActual('uuid'),
v4: jest.fn(() => 'mocked-file-id'),
}));
@@ -62,6 +62,8 @@ type GenerateMorphOrRelationFlatFieldMetadataPairArgs = {
flatApplication: FlatApplication;
targetFieldName?: string;
junctionTargetFlatFieldMetadata?: FlatFieldMetadata;
sourceFieldUniversalIdentifier?: string;
targetFieldUniversalIdentifier?: string;
};
export type SourceTargetMorphOrRelationFlatFieldAndFlatIndex = {
@@ -79,6 +81,8 @@ export const generateMorphOrRelationFlatFieldMetadataPair = ({
morphId = null,
targetFieldName,
junctionTargetFlatFieldMetadata,
sourceFieldUniversalIdentifier: providedSourceFieldUniversalIdentifier,
targetFieldUniversalIdentifier: providedTargetFieldUniversalIdentifier,
}: GenerateMorphOrRelationFlatFieldMetadataPairArgs): SourceTargetMorphOrRelationFlatFieldAndFlatIndex => {
const sourceFlatFieldMetadataType = createFieldInput.type;
@@ -91,8 +95,10 @@ export const generateMorphOrRelationFlatFieldMetadataPair = ({
junctionTargetFieldUniversalIdentifier:
junctionTargetFlatFieldMetadata?.universalIdentifier,
});
const sourceFieldUniversalIdentifier = v4();
const targetFieldUniversalIdentifier = v4();
const sourceFieldUniversalIdentifier =
providedSourceFieldUniversalIdentifier ?? v4();
const targetFieldUniversalIdentifier =
providedTargetFieldUniversalIdentifier ?? v4();
const defaultDescriptionFromField =
buildDescriptionForRelationFieldMetadataOnFromField({
@@ -1,4 +1,5 @@
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';
@@ -66,6 +67,30 @@ export const validateObjectMetadataSystemFieldsIntegrity = ({
});
}
}
// 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,
});
}
}
}
@@ -1,5 +1,5 @@
import { getFieldUniversalIdentifier } from 'twenty-shared/application';
import { FieldMetadataType } from 'twenty-shared/types';
import { v4 } from 'uuid';
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';
@@ -39,10 +39,17 @@ const buildObjectSystemFlatFieldMetadatas = ({
updatedBy,
} = PARTIAL_SYSTEM_FLAT_FIELD_METADATAS;
const computeFieldUniversalIdentifier = (name: string) =>
getFieldUniversalIdentifier({
applicationUniversalIdentifier,
objectUniversalIdentifier: objectMetadataUniversalIdentifier,
name,
});
return {
id: {
...id,
universalIdentifier: v4(),
universalIdentifier: computeFieldUniversalIdentifier(id.name),
applicationUniversalIdentifier,
objectMetadataUniversalIdentifier,
createdAt: now,
@@ -50,7 +57,7 @@ const buildObjectSystemFlatFieldMetadatas = ({
},
createdAt: {
...createdAt,
universalIdentifier: v4(),
universalIdentifier: computeFieldUniversalIdentifier(createdAt.name),
applicationUniversalIdentifier,
objectMetadataUniversalIdentifier,
createdAt: now,
@@ -58,7 +65,7 @@ const buildObjectSystemFlatFieldMetadatas = ({
},
createdBy: {
...createdBy,
universalIdentifier: v4(),
universalIdentifier: computeFieldUniversalIdentifier(createdBy.name),
applicationUniversalIdentifier,
objectMetadataUniversalIdentifier,
createdAt: now,
@@ -66,7 +73,7 @@ const buildObjectSystemFlatFieldMetadatas = ({
},
deletedAt: {
...deletedAt,
universalIdentifier: v4(),
universalIdentifier: computeFieldUniversalIdentifier(deletedAt.name),
applicationUniversalIdentifier,
objectMetadataUniversalIdentifier,
createdAt: now,
@@ -74,7 +81,7 @@ const buildObjectSystemFlatFieldMetadatas = ({
},
position: {
...position,
universalIdentifier: v4(),
universalIdentifier: computeFieldUniversalIdentifier(position.name),
applicationUniversalIdentifier,
objectMetadataUniversalIdentifier,
createdAt: now,
@@ -82,7 +89,7 @@ const buildObjectSystemFlatFieldMetadatas = ({
},
searchVector: {
...searchVector,
universalIdentifier: v4(),
universalIdentifier: computeFieldUniversalIdentifier(searchVector.name),
applicationUniversalIdentifier,
objectMetadataUniversalIdentifier,
createdAt: now,
@@ -91,7 +98,7 @@ const buildObjectSystemFlatFieldMetadatas = ({
},
updatedAt: {
...updatedAt,
universalIdentifier: v4(),
universalIdentifier: computeFieldUniversalIdentifier(updatedAt.name),
applicationUniversalIdentifier,
objectMetadataUniversalIdentifier,
createdAt: now,
@@ -99,7 +106,7 @@ const buildObjectSystemFlatFieldMetadatas = ({
},
updatedBy: {
...updatedBy,
universalIdentifier: v4(),
universalIdentifier: computeFieldUniversalIdentifier(updatedBy.name),
applicationUniversalIdentifier,
objectMetadataUniversalIdentifier,
createdAt: now,
@@ -125,7 +132,11 @@ export const buildDefaultFlatFieldMetadatasForCustomObject = ({
type: FieldMetadataType.TEXT,
isLabelSyncedWithName: false,
isUnique: false,
universalIdentifier: v4(),
universalIdentifier: getFieldUniversalIdentifier({
applicationUniversalIdentifier,
objectUniversalIdentifier: objectMetadataUniversalIdentifier,
name: 'name',
}),
name: 'name',
label: 'Name',
icon: 'IconAbc',
@@ -1,3 +1,4 @@
import { getFieldUniversalIdentifier } from 'twenty-shared/application';
import {
STANDARD_OBJECTS,
DEFAULT_RELATIONS_OBJECTS_STANDARD_IDS,
@@ -117,6 +118,22 @@ export const buildDefaultRelationFlatFieldMetadatasForCustomObject = ({
const morphId =
morphIdByRelationObjectNameSingular[objectMetadataNameSingular];
// Default relation fields get deterministic universal identifiers so
// that provisioning the same custom object always yields the same
// identifiers (matching the SDK manifest derivation).
const sourceFieldUniversalIdentifier = getFieldUniversalIdentifier({
applicationUniversalIdentifier: flatApplication.universalIdentifier,
objectUniversalIdentifier:
sourceFlatObjectMetadata.universalIdentifier,
name: targetFlatObjectMetadata.namePlural,
});
const targetFieldUniversalIdentifier = getFieldUniversalIdentifier({
applicationUniversalIdentifier: flatApplication.universalIdentifier,
objectUniversalIdentifier:
targetFlatObjectMetadata.universalIdentifier,
name: fieldName,
});
const { flatFieldMetadatas, indexMetadatas } =
generateMorphOrRelationFlatFieldMetadataPair({
sourceFlatObjectMetadata,
@@ -128,6 +145,8 @@ export const buildDefaultRelationFlatFieldMetadatasForCustomObject = ({
sourceFlatObjectMetadataJoinColumnName: joinColumnName,
morphId,
targetFieldName: fieldName,
sourceFieldUniversalIdentifier,
targetFieldUniversalIdentifier,
createFieldInput: {
icon: standardFieldProperties.icon,
type: FieldMetadataType.RELATION,
@@ -3,6 +3,7 @@ import { getStandardObjectMetadataRelatedEntityIds } from 'src/engine/workspace-
let uuidCounter = 0;
jest.mock('uuid', () => ({
...jest.requireActual('uuid'),
v4: jest.fn(
() => `00000000-0000-0000-0000-${String(++uuidCounter).padStart(12, '0')}`,
),
@@ -3,6 +3,7 @@ import { v4 } from 'uuid';
import { getStandardPageLayoutMetadataRelatedEntityIds } from 'src/engine/workspace-manager/twenty-standard-application/utils/get-standard-page-layout-metadata-related-entity-ids.util';
jest.mock('uuid', () => ({
...jest.requireActual('uuid'),
v4: jest.fn(),
}));
@@ -32,6 +32,7 @@ import { MessagingMessageListFetchJob } from 'src/modules/messaging/message-impo
import { SyncMessageFoldersService } from 'src/modules/messaging/message-folder-manager/services/sync-message-folders.service';
jest.mock('uuid', () => ({
...jest.requireActual('uuid'),
v4: jest.fn(() => 'mocked-uuid'),
}));
@@ -174,6 +174,43 @@ exports[`Sync application should fail due to object system fields integrity when
}
`;
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<String>,
},
"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": {
@@ -188,6 +225,12 @@ exports[`Sync application should fail due to object system fields integrity when
"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",
@@ -303,6 +346,12 @@ exports[`Sync application should fail due to object system fields integrity when
"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",
@@ -42,6 +42,7 @@ describe('Manifest sync - dry run', () => {
it('returns the planned actions without applying them', async () => {
const ticketObject = buildDefaultObjectManifest({
applicationUniversalIdentifier: TEST_APP_ID,
nameSingular: 'dryRunTicket',
namePlural: 'dryRunTickets',
labelSingular: 'Dry Run Ticket',
@@ -55,6 +56,7 @@ describe('Manifest sync - dry run', () => {
});
const invoiceObject = buildDefaultObjectManifest({
applicationUniversalIdentifier: TEST_APP_ID,
nameSingular: 'dryRunInvoice',
namePlural: 'dryRunInvoices',
labelSingular: 'Dry Run Invoice',
@@ -47,7 +47,9 @@ describe('Sync application should fail when creating permissions on a standard r
label: 'Stolen Admin Role',
description:
'Attempts to add permissions to the standard admin role',
permissionFlagUniversalIdentifiers: [SystemPermissionFlag.WORKSPACE],
permissionFlagUniversalIdentifiers: [
SystemPermissionFlag.WORKSPACE,
],
},
],
},
@@ -44,7 +44,9 @@ describe('Sync application should fail when retargeting existing permissions to
universalIdentifier: TEST_ROLE_ID,
label: 'App Default Role',
description: 'Default role for the test app',
permissionFlagUniversalIdentifiers: [SystemPermissionFlag.WORKSPACE],
permissionFlagUniversalIdentifiers: [
SystemPermissionFlag.WORKSPACE,
],
},
],
},
@@ -69,7 +71,9 @@ describe('Sync application should fail when retargeting existing permissions to
universalIdentifier: STANDARD_ROLE.admin.universalIdentifier,
label: 'Admin',
description: 'Attempts to retarget permission flag to admin role',
permissionFlagUniversalIdentifiers: [SystemPermissionFlag.WORKSPACE],
permissionFlagUniversalIdentifiers: [
SystemPermissionFlag.WORKSPACE,
],
},
],
},
@@ -32,6 +32,7 @@ describe('Sync application should surface a human error on flat-entity map confl
it('should fail with an installation error naming the object when two objects share a universalIdentifier', async () => {
const firstObject = buildDefaultObjectManifest({
applicationUniversalIdentifier: TEST_APP_ID,
nameSingular: 'invoice',
namePlural: 'invoices',
labelSingular: 'Invoice',
@@ -40,6 +41,7 @@ describe('Sync application should surface a human error on flat-entity map confl
});
const conflictingObject = buildDefaultObjectManifest({
applicationUniversalIdentifier: TEST_APP_ID,
nameSingular: 'invoiceDuplicate',
namePlural: 'invoiceDuplicates',
labelSingular: 'Invoice Duplicate',
@@ -65,6 +67,7 @@ describe('Sync application should surface a human error on flat-entity map confl
it('should fail with an installation error naming the field when two fields of an object share a universalIdentifier', async () => {
const objectWithConflictingFields = buildDefaultObjectManifest({
applicationUniversalIdentifier: TEST_APP_ID,
nameSingular: 'invoice',
namePlural: 'invoices',
labelSingular: 'Invoice',
@@ -10,10 +10,16 @@ import {
eachTestingContextFilter,
} from 'twenty-shared/testing';
import { FieldMetadataType } from 'twenty-shared/types';
import { v4 as uuidv4 } from 'uuid';
import { v4 as uuidv4, v5 as uuidv5 } from 'uuid';
const TEST_APP_ID = uuidv4();
const TEST_ROLE_ID = uuidv4();
// 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;
@@ -43,8 +49,8 @@ const buildObjectWithLabelField = ({
description: string;
additionalFields?: ObjectManifest['fields'];
}): Pick<Manifest, 'objects' | 'fields'> => {
const objectId = uuidv4();
const labelFieldId = uuidv4();
const objectId = computeDeterministicTestUuid(nameSingular);
const labelFieldId = computeDeterministicTestUuid(`${nameSingular}-title`);
return {
objects: [
@@ -90,6 +96,8 @@ const buildDefaultObjectWithModifiedSearchVector = ({
searchVectorOverrides: Partial<ObjectManifest['fields'][number]>;
}): Pick<Manifest, 'objects' | 'fields'> => {
const defaultObject = buildDefaultObjectManifest({
applicationUniversalIdentifier: TEST_APP_ID,
universalIdentifier: computeDeterministicTestUuid(nameSingular),
nameSingular,
namePlural,
labelSingular,
@@ -144,7 +152,8 @@ const failingSyncApplicationSystemFieldsTestCases: SyncApplicationTestingContext
description: 'Object with wrong id field type',
additionalFields: [
{
universalIdentifier: uuidv4(),
universalIdentifier:
computeDeterministicTestUuid('wrongIdTypeObject-id'),
type: FieldMetadataType.TEXT,
name: 'id',
label: 'Id',
@@ -183,7 +192,9 @@ const failingSyncApplicationSystemFieldsTestCases: SyncApplicationTestingContext
description: 'Object with wrong position field type',
additionalFields: [
{
universalIdentifier: uuidv4(),
universalIdentifier: computeDeterministicTestUuid(
'wrongPositionObject-position',
),
type: FieldMetadataType.TEXT,
name: 'position',
label: 'Position',
@@ -231,16 +242,60 @@ const failingSyncApplicationSystemFieldsTestCases: SyncApplicationTestingContext
),
},
},
{
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 = uuidv4();
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',
@@ -300,6 +355,7 @@ describe('Sync application should fail due to object system fields integrity', (
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',
@@ -350,6 +406,7 @@ describe('Sync application should fail due to object system fields integrity', (
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',
@@ -47,6 +47,7 @@ const buildCollidingManifest = (): Manifest => {
const uniqueFieldUniversalIdentifier = uuidv4();
const object = buildDefaultObjectManifest({
applicationUniversalIdentifier: TEST_APP_ID,
nameSingular: OBJECT_NAME_SINGULAR,
namePlural: 'collidingIndexObjects',
labelSingular: 'Colliding Index Object',
@@ -85,8 +85,7 @@ const FAILING_TEST_CASES: EachTestingContext<TestContext>[] = [
},
},
{
title:
'when filePath is a folder path without extension (bare UUID)',
title: 'when filePath is a folder path without extension (bare UUID)',
context: {
applicationUniversalIdentifier: TEST_APP_ID,
fileFolder: 'BuiltFrontComponent',
@@ -94,8 +93,7 @@ const FAILING_TEST_CASES: EachTestingContext<TestContext>[] = [
},
},
{
title:
'when filePath is a nested folder path without extension',
title: 'when filePath is a nested folder path without extension',
context: {
applicationUniversalIdentifier: TEST_APP_ID,
fileFolder: 'Source',
@@ -121,7 +119,8 @@ const FAILING_TEST_CASES: EachTestingContext<TestContext>[] = [
},
},
{
title: 'when filePath has an invalid extension for Source (.js instead of .ts)',
title:
'when filePath has an invalid extension for Source (.js instead of .ts)',
context: {
applicationUniversalIdentifier: TEST_APP_ID,
fileFolder: 'Source',
@@ -64,10 +64,7 @@ const buildManifest = ({
});
};
const timeSync = async (
label: string,
manifest: Manifest,
): Promise<number> => {
const timeSync = async (label: string, manifest: Manifest): Promise<number> => {
const start = performance.now();
await syncApplication({ manifest, expectToFail: false });
@@ -249,10 +249,10 @@ describe('Marketplace Catalog Sync (integration)', () => {
await fs.writeFile(join(sourceDir, 'manifest.json'), manifest);
await fs.writeFile(join(sourceDir, 'package.json'), packageJson);
await tar.create(
{ file: tarballPath, gzip: true, cwd: sourceDir },
['manifest.json', 'package.json'],
);
await tar.create({ file: tarballPath, gzip: true, cwd: sourceDir }, [
'manifest.json',
'package.json',
]);
const tarballBuffer = await fs.readFile(tarballPath);
@@ -20,6 +20,7 @@ const TEST_SECOND_FIELD_ID = uuidv4();
const TEST_NUMBER_FIELD_ID = uuidv4();
const TEST_OBJECT = buildDefaultObjectManifest({
applicationUniversalIdentifier: TEST_APP_ID,
nameSingular: 'ticket',
namePlural: 'tickets',
labelSingular: 'Ticket',
@@ -48,6 +48,7 @@ describe('Manifest update - objects', () => {
it('should create a new object when added to manifest on second sync', async () => {
const ticketObject = buildDefaultObjectManifest({
applicationUniversalIdentifier: TEST_APP_ID,
nameSingular: 'ticket',
namePlural: 'tickets',
labelSingular: 'Ticket',
@@ -75,6 +76,7 @@ describe('Manifest update - objects', () => {
});
const invoiceObject = buildDefaultObjectManifest({
applicationUniversalIdentifier: TEST_APP_ID,
nameSingular: 'invoice',
namePlural: 'invoices',
labelSingular: 'Invoice',
@@ -113,6 +115,7 @@ describe('Manifest update - objects', () => {
it('should update object properties when changed in manifest on second sync', async () => {
const universalIdentifier = uuidv4();
const ticketObject = buildDefaultObjectManifest({
applicationUniversalIdentifier: TEST_APP_ID,
nameSingular: 'ticket',
namePlural: 'tickets',
labelSingular: 'Ticket',
@@ -173,6 +176,7 @@ describe('Manifest update - objects', () => {
it('should delete an object when removed from manifest on second sync', async () => {
const ticketObject = buildDefaultObjectManifest({
applicationUniversalIdentifier: TEST_APP_ID,
nameSingular: 'ticket',
namePlural: 'tickets',
labelSingular: 'Ticket',
@@ -182,6 +186,7 @@ describe('Manifest update - objects', () => {
});
const invoiceObject = buildDefaultObjectManifest({
applicationUniversalIdentifier: TEST_APP_ID,
nameSingular: 'invoice',
namePlural: 'invoices',
labelSingular: 'Invoice',
@@ -29,6 +29,7 @@ const INITIAL_ANY_FIELD_FILTER_VALUE = 'arm';
const UPDATED_ANY_FIELD_FILTER_VALUE = 'tail';
const HUMAN_OBJECT = buildDefaultObjectManifest({
applicationUniversalIdentifier: TEST_APP_ID,
nameSingular: 'human',
namePlural: 'humans',
labelSingular: 'Human',
@@ -18,6 +18,7 @@ const TEST_APP_ID = uuidv4();
const TEST_ROLE_ID = uuidv4();
const TEST_OBJECT = buildDefaultObjectManifest({
applicationUniversalIdentifier: TEST_APP_ID,
nameSingular: 'uniqueRenameObject',
namePlural: 'uniqueRenameObjects',
labelSingular: 'Unique Rename Object',
@@ -13,6 +13,7 @@ const TEST_ROLE_ID = uuidv4();
const REUSABLE_UID = uuidv4();
const TEST_OBJECT = buildDefaultObjectManifest({
applicationUniversalIdentifier: TEST_APP_ID,
universalIdentifier: REUSABLE_UID,
nameSingular: 'ephemeral',
namePlural: 'ephemerals',
@@ -19,6 +19,7 @@ const TEST_FIELD_ID = uuidv4();
const TEST_SKILL_ID = uuidv4();
const TEST_OBJECT = buildDefaultObjectManifest({
applicationUniversalIdentifier: TEST_APP_ID,
nameSingular: 'ticket',
namePlural: 'tickets',
labelSingular: 'Ticket',
@@ -172,6 +173,7 @@ describe('syncApplication', () => {
const updatedFieldId = uuidv4();
const testObject = buildDefaultObjectManifest({
applicationUniversalIdentifier: TEST_APP_ID,
nameSingular: 'ticket',
namePlural: 'tickets',
labelSingular: 'Ticket',
@@ -1,7 +1,12 @@
import { type ObjectManifest } from 'twenty-shared/application';
import {
getFieldUniversalIdentifier,
type ObjectManifest,
} from 'twenty-shared/application';
import { FieldMetadataType } from 'twenty-shared/types';
import { v4 as uuidv4 } from 'uuid';
// System field universal identifiers are server-owned and validated against
// the deterministic derivation, so the manifest must carry the derived values.
export const buildDefaultObjectManifest = ({
nameSingular,
namePlural,
@@ -12,6 +17,7 @@ export const buildDefaultObjectManifest = ({
additionalFields = [],
universalIdentifier,
labelIdentifierFieldMetadataUniversalIdentifier,
applicationUniversalIdentifier,
}: {
nameSingular: string;
namePlural: string;
@@ -22,11 +28,22 @@ export const buildDefaultObjectManifest = ({
additionalFields?: ObjectManifest['fields'];
universalIdentifier?: string;
labelIdentifierFieldMetadataUniversalIdentifier?: string;
applicationUniversalIdentifier: string;
}): ObjectManifest => {
const idFieldUniversalIdentifier = uuidv4();
const objectUniversalIdentifier = universalIdentifier ?? uuidv4();
const computeSystemFieldUniversalIdentifier = (fieldName: string) =>
getFieldUniversalIdentifier({
applicationUniversalIdentifier,
objectUniversalIdentifier,
name: fieldName,
});
const idFieldUniversalIdentifier =
computeSystemFieldUniversalIdentifier('id');
return {
universalIdentifier: universalIdentifier ?? uuidv4(),
universalIdentifier: objectUniversalIdentifier,
labelIdentifierFieldMetadataUniversalIdentifier:
labelIdentifierFieldMetadataUniversalIdentifier ??
idFieldUniversalIdentifier,
@@ -44,43 +61,44 @@ export const buildDefaultObjectManifest = ({
label: 'Id',
},
{
universalIdentifier: uuidv4(),
universalIdentifier: computeSystemFieldUniversalIdentifier('createdAt'),
type: FieldMetadataType.DATE_TIME,
name: 'createdAt',
label: 'Creation date',
},
{
universalIdentifier: uuidv4(),
universalIdentifier: computeSystemFieldUniversalIdentifier('updatedAt'),
type: FieldMetadataType.DATE_TIME,
name: 'updatedAt',
label: 'Last update',
},
{
universalIdentifier: uuidv4(),
universalIdentifier: computeSystemFieldUniversalIdentifier('deletedAt'),
type: FieldMetadataType.DATE_TIME,
name: 'deletedAt',
label: 'Deleted at',
},
{
universalIdentifier: uuidv4(),
universalIdentifier: computeSystemFieldUniversalIdentifier('createdBy'),
type: FieldMetadataType.ACTOR,
name: 'createdBy',
label: 'Created by',
},
{
universalIdentifier: uuidv4(),
universalIdentifier: computeSystemFieldUniversalIdentifier('updatedBy'),
type: FieldMetadataType.ACTOR,
name: 'updatedBy',
label: 'Updated by',
},
{
universalIdentifier: uuidv4(),
universalIdentifier: computeSystemFieldUniversalIdentifier('position'),
type: FieldMetadataType.POSITION,
name: 'position',
label: 'Position',
},
{
universalIdentifier: uuidv4(),
universalIdentifier:
computeSystemFieldUniversalIdentifier('searchVector'),
type: FieldMetadataType.TS_VECTOR,
name: 'searchVector',
label: 'Search vector',
@@ -0,0 +1,186 @@
import { findManyApplications } from 'test/integration/graphql/utils/find-many-applications.util';
import { findManyFieldsMetadata } from 'test/integration/metadata/suites/field-metadata/utils/find-many-fields-metadata.util';
import { createOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/create-one-object-metadata.util';
import { deleteOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/delete-one-object-metadata.util';
import { getFieldUniversalIdentifier } from 'twenty-shared/application';
import { capitalize, isDefined } from 'twenty-shared/utils';
const SYSTEM_FIELD_NAMES = [
'id',
'name',
'createdAt',
'updatedAt',
'deletedAt',
'createdBy',
'updatedBy',
'position',
'searchVector',
];
const DEFAULT_RELATION_FORWARD_FIELD_NAMES = [
'timelineActivities',
'attachments',
'noteTargets',
'taskTargets',
];
const OBJECT_NAME_SINGULAR = 'rocketForUniversalIdentifier';
type FetchedField = {
id: string;
name: string;
universalIdentifier: string;
relation: {
targetFieldMetadata: {
id: string;
name: string;
universalIdentifier: string;
};
targetObjectMetadata: {
id: string;
nameSingular: string;
universalIdentifier: string;
};
} | null;
};
describe('Custom object creation deterministic field universal identifiers', () => {
let createdObjectMetadataId: string;
let objectUniversalIdentifier: string;
let applicationUniversalIdentifier: string;
let fetchedFields: FetchedField[];
beforeAll(async () => {
const { data: createObjectData } = await createOneObjectMetadata({
expectToFail: false,
input: {
nameSingular: OBJECT_NAME_SINGULAR,
namePlural: `${OBJECT_NAME_SINGULAR}s`,
labelSingular: 'Rocket For Universal Identifier',
labelPlural: 'Rockets For Universal Identifier',
icon: 'IconRocket',
},
gqlFields: 'id universalIdentifier applicationId',
});
createdObjectMetadataId = createObjectData.createOneObject.id;
objectUniversalIdentifier =
createObjectData.createOneObject.universalIdentifier;
const { data: findManyApplicationsData } = await findManyApplications({
expectToFail: false,
});
const workspaceCustomApplication =
findManyApplicationsData.findManyApplications.find(
(application) =>
application.id === createObjectData.createOneObject.applicationId,
);
if (!isDefined(workspaceCustomApplication)) {
throw new Error(
'Could not resolve the application of the created custom object',
);
}
applicationUniversalIdentifier =
workspaceCustomApplication.universalIdentifier;
const { fields } = await findManyFieldsMetadata({
expectToFail: false,
input: {
filter: { objectMetadataId: { eq: createdObjectMetadataId } },
paging: { first: 100 },
},
gqlFields: `
id
name
universalIdentifier
relation {
targetFieldMetadata { id name universalIdentifier }
targetObjectMetadata { id nameSingular universalIdentifier }
}
`,
});
fetchedFields = fields.map((edge: { node: FetchedField }) => edge.node);
});
afterAll(async () => {
await deleteOneObjectMetadata({
expectToFail: false,
input: { idToDelete: createdObjectMetadataId },
});
});
it.each(SYSTEM_FIELD_NAMES)(
'should derive the %s system field universal identifier deterministically',
(systemFieldName) => {
const systemField = fetchedFields.find(
(field) => field.name === systemFieldName,
);
expect(systemField).toBeDefined();
expect(systemField?.universalIdentifier).toBe(
getFieldUniversalIdentifier({
applicationUniversalIdentifier,
objectUniversalIdentifier,
name: systemFieldName,
}),
);
},
);
it.each(DEFAULT_RELATION_FORWARD_FIELD_NAMES)(
'should derive the %s default relation field universal identifiers deterministically',
async (forwardFieldName) => {
const forwardField = fetchedFields.find(
(field) => field.name === forwardFieldName,
);
expect(forwardField).toBeDefined();
expect(forwardField?.universalIdentifier).toBe(
getFieldUniversalIdentifier({
applicationUniversalIdentifier,
objectUniversalIdentifier,
name: forwardFieldName,
}),
);
const standardRelationObject =
forwardField?.relation?.targetObjectMetadata;
expect(standardRelationObject).toBeDefined();
if (!isDefined(standardRelationObject)) {
return;
}
const { fields: standardObjectFieldEdges } = await findManyFieldsMetadata(
{
expectToFail: false,
input: {
filter: { objectMetadataId: { eq: standardRelationObject.id } },
paging: { first: 1000 },
},
gqlFields: 'id name universalIdentifier',
},
);
const storedReverseFieldName = `target${capitalize(OBJECT_NAME_SINGULAR)}`;
const reverseField = standardObjectFieldEdges
.map((edge: { node: FetchedField }) => edge.node)
.find((field: FetchedField) => field.name === storedReverseFieldName);
expect(reverseField).toBeDefined();
expect(reverseField?.universalIdentifier).toBe(
getFieldUniversalIdentifier({
applicationUniversalIdentifier,
objectUniversalIdentifier: standardRelationObject.universalIdentifier,
name: storedReverseFieldName,
}),
);
},
);
});
@@ -0,0 +1,10 @@
import { STANDARD_OBJECTS } from '@/metadata/constants/standard-object.constant';
// If this test fails, a universal identifier changed: make sure the
// change is intentional and ships with a coordinated backfill of existing
// workspaces before updating the snapshot.
describe('STANDARD_OBJECTS universal identifiers', () => {
it('should never change without an explicit snapshot update', () => {
expect(STANDARD_OBJECTS).toMatchSnapshot();
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,38 @@
import { TWENTY_STANDARD_APPLICATION_UNIVERSAL_IDENTIFIER } from '@/application/constants/TwentyStandardApplicationUniversalIdentifier';
import { getFieldUniversalIdentifier } from '@/application/deterministic-identifier/get-field-universal-identifier.util';
export const STANDARD_OBJECT_SYSTEM_FIELD_NAMES = [
'id',
'createdAt',
'updatedAt',
'deletedAt',
'createdBy',
'updatedBy',
'position',
'searchVector',
] as const;
type StandardObjectSystemFieldName =
(typeof STANDARD_OBJECT_SYSTEM_FIELD_NAMES)[number];
// System field universal identifiers are deterministically derived from the
// standard application universal identifier, the object universal identifier
// and the field name. Renaming a system field therefore changes its
// universal identifier and must never happen without a coordinated backfill
// of existing workspaces.
export const buildStandardObjectSystemFields = (
objectUniversalIdentifier: string,
): Record<StandardObjectSystemFieldName, { universalIdentifier: string }> =>
Object.fromEntries(
STANDARD_OBJECT_SYSTEM_FIELD_NAMES.map((fieldName) => [
fieldName,
{
universalIdentifier: getFieldUniversalIdentifier({
applicationUniversalIdentifier:
TWENTY_STANDARD_APPLICATION_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier,
name: fieldName,
}),
},
]),
) as Record<StandardObjectSystemFieldName, { universalIdentifier: string }>;