88424611ec
# Introduction ## Centralize system field definitions - Extract a single `PARTIAL_SYSTEM_FLAT_FIELD_METADATAS` constant as the source of truth for all 8 system fields (`id`, `createdAt`, `updatedAt`, `deletedAt`, `createdBy`, `updatedBy`, `position`, `searchVector`), eliminating duplication across custom object and standard app field builders - Refactor `buildDefaultFlatFieldMetadatasForCustomObject` to use the shared constant via a new `buildObjectSystemFlatFieldMetadatas` helper ## Mark system fields as `isSystem: true` - Fields `id`, `createdAt`, `updatedAt`, `deletedAt`, `createdBy`, `updatedBy`, `position`, `searchVector` are now properly flagged as system fields across all standard objects and custom object creation - Standard app field builders for all ~30 standard objects updated to set `isSystem: true` on `createdAt`, `updatedAt`, `deletedAt`, `createdBy`, `updatedBy` - System-only standard objects (blocklist, calendar channels, message threads, etc.) now also include `createdBy`, `updatedBy`, `position`, `searchVector` field definitions that were previously missing ## Validate system fields on object creation - New transversal validation (`crossEntityTransversalValidation`) runs after all atomic entity validations in the build orchestrator, ensuring all 8 system fields are present with correct `type` and `isSystem: true` when an object is created - New `buildUniversalFlatObjectFieldByNameAndJoinColumnMaps` utility to resolve field names to universal identifiers for a given object - New exception codes: `MISSING_SYSTEM_FIELD` and `INVALID_SYSTEM_FIELD` on `ObjectMetadataExceptionCode` ## Protect system fields and objects from mutation - Field validators now block update/delete of `isSystem` fields by non-system callers (`FIELD_MUTATION_NOT_ALLOWED`) - Object validators now block update/delete of `isSystem` objects by non-system callers - `POSITION` and `TS_VECTOR` field type validators replaced: instead of rejecting creation outright, they now validate that the field is named correctly (`position` / `searchVector`) and has `isSystem: true` ## Distinguish `isSystemBuild` from `isCallerTwentyStandardApp` - New `isCallerTwentyStandardApp` utility checks whether the caller's `applicationUniversalIdentifier` matches the twenty standard app - Name-sync logic (`isFlatFieldMetadataNameSyncedWithLabel`, `areFlatObjectMetadataNamesSyncedWithLabels`) refactored to use `isCallerTwentyStandardApp` for custom suffix decisions, keeping `isSystemBuild` for mutation permission checks - `WorkspaceMigrationBuilderOptions` type updated to include `applicationUniversalIdentifier` ## Adapt frontend filtering - New `HIDDEN_SYSTEM_FIELD_NAMES` constant (`id`, `position`, `searchVector`) and `isHiddenSystemField` utility to only hide truly internal fields while keeping user-facing system fields (`createdAt`, `updatedAt`, `deletedAt`, `createdBy`, `updatedBy`) visible in the UI - ~20 frontend files updated to replace `!field.isSystem` checks with `!isHiddenSystemField(field)` across record index, settings, data model, charts, workflows, spreadsheet import, aggregations, and role permissions ## Add 1.19 upgrade commands - **`backfill-system-fields-is-system`**: Raw SQL command to set `isSystem = true` on existing workspace fields matching system field names, and fix `position` field type from `NUMBER` to `POSITION` for `favorite`/`favoriteFolder` objects. Includes proper cache invalidation. - **`add-missing-system-fields-to-standard-objects`**: Codegen'd workspace migration to create missing `position`, `searchVector`, `createdBy`, `updatedBy` fields on standard objects that didn't previously have them. Runs via `WorkspaceMigrationRunnerService` in a single transaction with idempotency check. **Known limitation**: assumes all standard objects exist and are valid in the target workspace. ## Add `universalIdentifier` for system fields in standard object constants - `standard-object.constant.ts` updated to include `universalIdentifier` for `createdBy`, `updatedBy`, `position`, and `searchVector` across all standard objects - `fieldManifestType.ts` updated to support the new field manifest shape ## System relation Completely removed and backfilled all `isSystem` relation to be false false As we won't require an object to have any relation system fields ## Add integration tests - New test suite `failing-sync-application-object-system-fields` covering: missing system fields, wrong field types (`id` as TEXT, `createdAt` as TEXT, `position` as TEXT), system field deletion attempts, and system field update attempts - New test utilities: `buildDefaultObjectManifest` (builds an object manifest with all 8 system fields) and `setupApplicationForSync` (centralizes application setup) - Existing successful sync test updated to verify system fields are created with correct properties ## Next step Make the builder scope the compared entity to be the currently built app + nor twenty standard app
311 lines
8.9 KiB
TypeScript
311 lines
8.9 KiB
TypeScript
import { expectOneNotInternalServerErrorSnapshot } from 'test/integration/graphql/utils/expect-one-not-internal-server-error-snapshot.util';
|
|
import { buildDefaultObjectManifest } from 'test/integration/metadata/suites/application/utils/build-default-object-manifest.util';
|
|
import { setupApplicationForSync } from 'test/integration/metadata/suites/application/utils/setup-application-for-sync.util';
|
|
import { syncApplication } from 'test/integration/metadata/suites/application/utils/sync-application.util';
|
|
import { uninstallApplication } from 'test/integration/metadata/suites/application/utils/uninstall-application.util';
|
|
import { type Manifest, type ObjectManifest } from 'twenty-shared/application';
|
|
import {
|
|
type EachTestingContext,
|
|
eachTestingContextFilter,
|
|
} from 'twenty-shared/testing';
|
|
import { FieldMetadataType } from 'twenty-shared/types';
|
|
import { v4 as uuidv4 } from 'uuid';
|
|
|
|
const TEST_APP_ID = uuidv4();
|
|
const TEST_ROLE_ID = uuidv4();
|
|
|
|
type TestContext = {
|
|
manifest: Manifest;
|
|
};
|
|
|
|
type SyncApplicationTestingContext = EachTestingContext<TestContext>[];
|
|
|
|
const buildBaseManifest = (
|
|
overrides: Pick<Manifest, 'objects' | 'fields'>,
|
|
): Manifest => ({
|
|
application: {
|
|
apiClientChecksum: '',
|
|
marketplaceData: undefined,
|
|
universalIdentifier: TEST_APP_ID,
|
|
defaultRoleUniversalIdentifier: TEST_ROLE_ID,
|
|
displayName: 'Test System Fields App',
|
|
description: 'App for testing system field validation',
|
|
icon: 'IconTestPipe',
|
|
applicationVariables: {},
|
|
packageJsonChecksum: null,
|
|
yarnLockChecksum: null,
|
|
},
|
|
roles: [
|
|
{
|
|
universalIdentifier: TEST_ROLE_ID,
|
|
label: 'Test Role',
|
|
description: 'A test role',
|
|
},
|
|
],
|
|
logicFunctions: [],
|
|
frontComponents: [],
|
|
publicAssets: [],
|
|
views: [],
|
|
navigationMenuItems: [],
|
|
pageLayouts: [],
|
|
...overrides,
|
|
});
|
|
|
|
const buildObjectWithLabelField = ({
|
|
nameSingular,
|
|
namePlural,
|
|
labelSingular,
|
|
labelPlural,
|
|
description,
|
|
additionalFields = [],
|
|
}: {
|
|
nameSingular: string;
|
|
namePlural: string;
|
|
labelSingular: string;
|
|
labelPlural: string;
|
|
description: string;
|
|
additionalFields?: ObjectManifest['fields'];
|
|
}): Pick<Manifest, 'objects' | 'fields'> => {
|
|
const objectId = uuidv4();
|
|
const labelFieldId = uuidv4();
|
|
|
|
return {
|
|
objects: [
|
|
{
|
|
universalIdentifier: objectId,
|
|
labelIdentifierFieldMetadataUniversalIdentifier: labelFieldId,
|
|
nameSingular,
|
|
namePlural,
|
|
labelSingular,
|
|
labelPlural,
|
|
description,
|
|
icon: 'IconTicket',
|
|
fields: [
|
|
{
|
|
universalIdentifier: labelFieldId,
|
|
type: FieldMetadataType.TEXT,
|
|
name: 'title',
|
|
label: 'Title',
|
|
description: 'Label identifier field',
|
|
icon: 'IconTextCaption',
|
|
},
|
|
...additionalFields,
|
|
],
|
|
},
|
|
],
|
|
fields: [],
|
|
};
|
|
};
|
|
|
|
const failingSyncApplicationSystemFieldsTestCases: SyncApplicationTestingContext =
|
|
[
|
|
{
|
|
title:
|
|
'when object is created without any system fields (missing all 8 system fields)',
|
|
context: {
|
|
manifest: buildBaseManifest(
|
|
buildObjectWithLabelField({
|
|
nameSingular: 'noSystemFieldsObject',
|
|
namePlural: 'noSystemFieldsObjects',
|
|
labelSingular: 'No System Fields Object',
|
|
labelPlural: 'No System Fields Objects',
|
|
description: 'Object with no system fields',
|
|
}),
|
|
),
|
|
},
|
|
},
|
|
{
|
|
title: 'when object has id field with wrong type (TEXT instead of UUID)',
|
|
context: {
|
|
manifest: buildBaseManifest(
|
|
buildObjectWithLabelField({
|
|
nameSingular: 'wrongIdTypeObject',
|
|
namePlural: 'wrongIdTypeObjects',
|
|
labelSingular: 'Wrong Id Type Object',
|
|
labelPlural: 'Wrong Id Type Objects',
|
|
description: 'Object with wrong id field type',
|
|
additionalFields: [
|
|
{
|
|
universalIdentifier: uuidv4(),
|
|
type: FieldMetadataType.TEXT,
|
|
name: 'id',
|
|
label: 'Id',
|
|
description: 'Id field with wrong type',
|
|
icon: 'IconKey',
|
|
},
|
|
],
|
|
}),
|
|
),
|
|
},
|
|
},
|
|
{
|
|
title:
|
|
'when object has createdAt field with wrong type (TEXT instead of DATE_TIME)',
|
|
context: {
|
|
manifest: buildBaseManifest(
|
|
buildObjectWithLabelField({
|
|
nameSingular: 'wrongCreatedAtObject',
|
|
namePlural: 'wrongCreatedAtObjects',
|
|
labelSingular: 'Wrong CreatedAt Object',
|
|
labelPlural: 'Wrong CreatedAt Objects',
|
|
description: 'Object with wrong createdAt field type',
|
|
additionalFields: [
|
|
{
|
|
universalIdentifier: uuidv4(),
|
|
type: FieldMetadataType.TEXT,
|
|
name: 'createdAt',
|
|
label: 'Created At',
|
|
description: 'Created at field with wrong type',
|
|
icon: 'IconCalendar',
|
|
},
|
|
],
|
|
}),
|
|
),
|
|
},
|
|
},
|
|
{
|
|
title:
|
|
'when object has position field with wrong type (TEXT instead of POSITION)',
|
|
context: {
|
|
manifest: buildBaseManifest(
|
|
buildObjectWithLabelField({
|
|
nameSingular: 'wrongPositionObject',
|
|
namePlural: 'wrongPositionObjects',
|
|
labelSingular: 'Wrong Position Object',
|
|
labelPlural: 'Wrong Position Objects',
|
|
description: 'Object with wrong position field type',
|
|
additionalFields: [
|
|
{
|
|
universalIdentifier: uuidv4(),
|
|
type: FieldMetadataType.TEXT,
|
|
name: 'position',
|
|
label: 'Position',
|
|
description: 'Position field with wrong type',
|
|
icon: 'IconArrowsSort',
|
|
},
|
|
],
|
|
}),
|
|
),
|
|
},
|
|
},
|
|
];
|
|
|
|
describe('Sync application should fail due to object system fields integrity', () => {
|
|
let appCreated = false;
|
|
|
|
beforeAll(async () => {
|
|
await setupApplicationForSync({
|
|
applicationUniversalIdentifier: TEST_APP_ID,
|
|
name: 'Test System Fields App',
|
|
description: 'App for testing system field validation',
|
|
sourcePath: 'test-system-fields',
|
|
});
|
|
|
|
appCreated = true;
|
|
}, 60000);
|
|
|
|
afterEach(async () => {
|
|
if (!appCreated) {
|
|
return;
|
|
}
|
|
|
|
await uninstallApplication({
|
|
universalIdentifier: TEST_APP_ID,
|
|
expectToFail: false,
|
|
});
|
|
});
|
|
|
|
it.each(
|
|
eachTestingContextFilter(failingSyncApplicationSystemFieldsTestCases),
|
|
)(
|
|
'$title',
|
|
async ({ context }) => {
|
|
const { errors } = await syncApplication({
|
|
manifest: context.manifest,
|
|
expectToFail: true,
|
|
});
|
|
|
|
expectOneNotInternalServerErrorSnapshot({ errors });
|
|
},
|
|
60000,
|
|
);
|
|
|
|
it('should fail when trying to delete a system field after a successful sync', async () => {
|
|
const testObject = buildDefaultObjectManifest({
|
|
nameSingular: 'deleteSystemFieldObject',
|
|
namePlural: 'deleteSystemFieldObjects',
|
|
labelSingular: 'Delete System Field Object',
|
|
labelPlural: 'Delete System Field Objects',
|
|
description: 'Object for testing system field deletion',
|
|
});
|
|
|
|
const validManifest = buildBaseManifest({
|
|
objects: [testObject],
|
|
fields: [],
|
|
});
|
|
|
|
await syncApplication({
|
|
manifest: validManifest,
|
|
expectToFail: false,
|
|
});
|
|
|
|
const manifestWithDeletedIdField = buildBaseManifest({
|
|
objects: [
|
|
{
|
|
...testObject,
|
|
fields: testObject.fields.filter((field) => field.name !== 'id'),
|
|
},
|
|
],
|
|
fields: [],
|
|
});
|
|
|
|
const { errors } = await syncApplication({
|
|
manifest: manifestWithDeletedIdField,
|
|
expectToFail: true,
|
|
});
|
|
|
|
expectOneNotInternalServerErrorSnapshot({ errors });
|
|
}, 60000);
|
|
|
|
it('should fail when trying to update a system field after a successful sync', async () => {
|
|
const testObject = buildDefaultObjectManifest({
|
|
nameSingular: 'updateSystemFieldObject',
|
|
namePlural: 'updateSystemFieldObjects',
|
|
labelSingular: 'Update System Field Object',
|
|
labelPlural: 'Update System Field Objects',
|
|
description: 'Object for testing system field update',
|
|
});
|
|
|
|
const validManifest = buildBaseManifest({
|
|
objects: [testObject],
|
|
fields: [],
|
|
});
|
|
|
|
await syncApplication({
|
|
manifest: validManifest,
|
|
expectToFail: false,
|
|
});
|
|
|
|
const manifestWithUpdatedIdField = buildBaseManifest({
|
|
objects: [
|
|
{
|
|
...testObject,
|
|
fields: testObject.fields.map((field) =>
|
|
field.name === 'id'
|
|
? { ...field, label: 'Modified Id Label' }
|
|
: field,
|
|
),
|
|
},
|
|
],
|
|
fields: [],
|
|
});
|
|
|
|
const { errors } = await syncApplication({
|
|
manifest: manifestWithUpdatedIdField,
|
|
expectToFail: true,
|
|
});
|
|
|
|
expectOneNotInternalServerErrorSnapshot({ errors });
|
|
}, 60000);
|
|
});
|