fix(server): type WasRemovedInUpgrade columns with WasRemovedInUpgrade<> (#21785)

## Context

WasRemovedInUpgrade type brand was introduced in
https://github.com/twentyhq/twenty/pull/21228/changes#diff-1b6d688610669a46b3ee8e3a41b1c7eb0ee03e19146d0d249f95df6e56164a92R15
for the `isCustom` property deprecation.
The @WasRemovedInUpgrade decorator and the WasRemovedInUpgrade<T> type
are meant to go together: the type brand makes the property optional in
every derived flat-entity type, so the column only needs to be declared
on the entity itself. RolePermissionFlagEntity.flag had the decorator
but was typed as a plain PermissionFlagType, forcing the property to be
supplied everywhere.

This PR:
- Types flag as WasRemovedInUpgrade<PermissionFlagType> (matching the
isCustom reference impl on object/field metadata).
- Removes the now-redundant flag from the flat-entity construction
sites, the create input, and the service call site — leaving it only on
the entity. The GraphQL RolePermissionFlagDTO.flag is kept (it's an API
field derived from permissionFlag.key, not the removed column).
- Fixes a latent brand-leak in the flat-entity config type: toStringify
is computed via object-detection, and a branded type reads as an object.
This was harmless for boolean but wrongly forced toStringify: true for
enum/string columns. Added UnwrapWasRemovedInUpgrade<T> and applied it
so the brand is transparent making the pattern work for any type, not
just
booleans.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21785?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
This commit is contained in:
Weiko
2026-06-18 15:23:24 +02:00
committed by GitHub
parent 2b3b2362db
commit cc4659ce11
8 changed files with 16 additions and 25 deletions
@@ -1,7 +1,3 @@
import {
PermissionFlagType,
SystemPermissionFlag,
} from 'twenty-shared/constants';
import { v5 } from 'uuid';
import { type UniversalFlatRolePermissionFlag } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-role-permission-flag.type';
@@ -9,10 +5,6 @@ import { type UniversalFlatRolePermissionFlag } from 'src/engine/workspace-manag
export const ROLE_PERMISSION_FLAG_UUID_NAMESPACE =
'b9a3b3b3-58a3-4f6c-9c1f-3a4f6c9c1f3a';
const SYSTEM_PERMISSION_FLAG_BY_UNIVERSAL_IDENTIFIER = Object.fromEntries(
Object.entries(SystemPermissionFlag).map(([key, uuid]) => [uuid, key]),
) as Record<string, PermissionFlagType | undefined>;
export const fromPermissionFlagToUniversalFlatRolePermissionFlag = ({
permissionFlagUniversalIdentifier,
roleUniversalIdentifier,
@@ -29,17 +21,11 @@ export const fromPermissionFlagToUniversalFlatRolePermissionFlag = ({
ROLE_PERMISSION_FLAG_UUID_NAMESPACE,
);
const resolvedFlag =
SYSTEM_PERMISSION_FLAG_BY_UNIVERSAL_IDENTIFIER[
permissionFlagUniversalIdentifier
];
return {
universalIdentifier,
applicationUniversalIdentifier,
roleUniversalIdentifier,
permissionFlagUniversalIdentifier,
flag: resolvedFlag as PermissionFlagType,
createdAt: now,
updatedAt: now,
};
@@ -12,6 +12,12 @@ export type WasRemovedInUpgrade<T> = T & {
readonly [wasRemovedInUpgradeBrand]?: true;
};
export type UnwrapWasRemovedInUpgrade<T> = [T] extends [
WasRemovedInUpgrade<infer TUnwrapped>,
]
? TUnwrapped
: T;
type WasRemovedInUpgradeKeys<TEntity> = {
[K in keyof TEntity]: typeof wasRemovedInUpgradeBrand extends keyof TEntity[K]
? K
@@ -1,5 +1,6 @@
import { type AllMetadataName } from 'twenty-shared/metadata';
import { type UnwrapWasRemovedInUpgrade } from 'src/engine/core-modules/upgrade/decorators/was-removed-in-upgrade.decorator';
import { type MetadataEntity } from 'src/engine/metadata-modules/flat-entity/types/metadata-entity.type';
import { type MetadataManyToOneJoinColumn } from 'src/engine/metadata-modules/flat-entity/types/metadata-many-to-one-join-column.type';
import { type ScalarFlatEntity } from 'src/engine/metadata-modules/flat-entity/types/scalar-flat-entity.type';
@@ -29,9 +30,13 @@ type MetadataEntityPropertyConfiguration<
toStringify: K extends ExtractJsonbProperties<MetadataEntity<TMetadataName>>
? true
: K extends keyof MetadataEntity<TMetadataName>
? NonNullable<MetadataEntity<TMetadataName>[K]> extends Date
? NonNullable<
UnwrapWasRemovedInUpgrade<MetadataEntity<TMetadataName>[K]>
> extends Date
? false
: HasObjectInUnion<MetadataEntity<TMetadataName>[K]>
: HasObjectInUnion<
UnwrapWasRemovedInUpgrade<MetadataEntity<TMetadataName>[K]>
>
: boolean;
toCompare: boolean;
isOverridable?: boolean;
@@ -21,7 +21,7 @@ export const fromCreateRolePermissionFlagInputToFlatRolePermissionFlagToCreate =
>): UniversalFlatRolePermissionFlag & {
id: string;
} => {
const { roleId, permissionFlagId, flag, universalIdentifier } =
const { roleId, permissionFlagId, universalIdentifier } =
createRolePermissionFlagInput;
const now = new Date().toISOString();
@@ -38,7 +38,6 @@ export const fromCreateRolePermissionFlagInputToFlatRolePermissionFlagToCreate =
applicationUniversalIdentifier: flatApplication.universalIdentifier,
permissionFlagUniversalIdentifier,
roleUniversalIdentifier,
flag,
createdAt: now,
updatedAt: now,
};
@@ -1,8 +1,5 @@
import { type PermissionFlagType } from 'twenty-shared/constants';
export type CreateRolePermissionFlagInput = {
roleId: string;
permissionFlagId: string;
flag: PermissionFlagType;
universalIdentifier?: string;
};
@@ -51,7 +51,7 @@ export class RolePermissionFlagEntity extends SyncableEntity {
'2.7.0_FinalizeRolePermissionFlagCutoverFastInstanceCommand_1779600000000',
})
@Column({ nullable: false, type: 'varchar' })
flag: PermissionFlagType;
flag: WasRemovedInUpgrade<PermissionFlagType>;
@WasIntroducedInUpgrade({
upgradeCommandName:
@@ -1,7 +1,6 @@
import { Injectable } from '@nestjs/common';
import { msg } from '@lingui/core/macro';
import { type PermissionFlagType } from 'twenty-shared/constants';
import { isDefined } from 'twenty-shared/utils';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
@@ -118,7 +117,6 @@ export class RolePermissionFlagService {
createRolePermissionFlagInput: {
roleId: input.roleId,
permissionFlagId: permissionFlag.id,
flag: permissionFlag.key as PermissionFlagType,
},
flatApplication: workspaceCustomFlatApplication,
flatPermissionFlagMaps,
@@ -294,7 +294,7 @@ export class WorkspaceRolesPermissionsCacheService extends WorkspaceCacheProvide
// cursor (@WasIntroducedInUpgrade), so fall back to the legacy `flag` column.
return (
rolePermissionFlag.permissionFlag?.universalIdentifier ??
SystemPermissionFlag[rolePermissionFlag.flag]
SystemPermissionFlag[rolePermissionFlag.flag as PermissionFlagType]
);
}
}