Drop legacy rolePermissionFlag.flag column + fallback logic (#20730)

## Summary
- **New fast migration**
`2-7-instance-command-fast-1779600000000-finalize-role-permission-flag-cutover.ts`:
  - `DROP CONSTRAINT IDX_ROLE_PERMISSION_FLAG_FLAG_ROLE_ID_UNIQUE`
  - `ALTER COLUMN permissionFlagId SET NOT NULL`
  - `DROP COLUMN flag`
- `down()` repopulates `flag` from the catalog via `permissionFlagId`
and restores the old unique.
- **Entity**: `RolePermissionFlagEntity` hides the `flag` column by
using the new decorator + drops old `@Unique` decorator;
`permissionFlagId` and the `permissionFlag` relation become
non-nullable.
- **Deletes** the synthesizer
`synthesize-flat-permission-flag-from-flag.util.ts` and every fallback
branch that used it

(`from-role-permission-flag-entity-to-flat-role-permission-flag.util.ts`,
`from-flat-role-permission-flag-to-role-permission-flag-dto.util.ts`,
`permissions.service.ts`,
`workspace-roles-permissions-cache.service.ts`,
`fromRoleEntityToRoleDto.util.ts`,
`flat-role-permission-flag-validator.service.ts`,
`role-permission-flag.service.ts:getEffectiveUniversalIdentifier`).
- **Write path**: ~~drops `flag` from `CreateRolePermissionFlagInput`,
the create util, and the application-manifest converter.~~
- **Metadata configs**: ~~removes `flag` from
`all-entity-properties-configuration-by-metadata-name.constant.ts`
(rolePermissionFlag block)~~ and flips `permissionFlag.isNullable` to
`false` in `all-many-to-one-metadata-relations.constant.ts`.

### Why the `flag` field stays declared in the entity

The decorator (`@WasRemovedInUpgrade`) is the right tool for the
lifecycle marker, but it's a **reflect-metadata** runtime decorator —
TypeScript can't see it at compile time. So while
the adapter
([`UpgradeAwareEntityMetadataAdapter`](packages/twenty-server/src/engine/twenty-orm/upgrade-aware/upgrade-aware-entity-metadata.adapter.ts))
now correctly flips
`isSelect`/`isInsert`/`isUpdate` to `false` once the drop migration's
cursor is crossed, the *static* TypeScript types derived from
`RolePermissionFlagEntity`
(`UniversalFlatRolePermissionFlag`, `FlatRolePermissionFlag`,
`MetadataEntityPropertyConfiguration<'rolePermissionFlag'>`, etc.) still
see `flag` as a required scalar property — because the entity declares
it.

That means every producer of one of those derived types must include
`flag`:
-
`from-create-role-permission-flag-input-to-flat-role-permission-flag-to-create.util.ts`
plumbs it through.
- `from-permission-flag-to-universal-flat-role-permission-flag.util.ts`
(the application-manifest converter) sets `flag: permissionFlag.flag`.
- `all-entity-properties-configuration-by-metadata-name.constant.ts` has
a `flag` entry under `rolePermissionFlag`.
- `CreateRolePermissionFlagInput` keeps the `flag` field.
- `RolePermissionFlagService.upsertPermissionFlags` passes `flag:
permissionFlag.key as PermissionFlagType` to the create util.

Explored phantom-brand approach (`RemovedInUpgrade<T>` wrapper on the
field type, key-filter mapped type applied inside `ScalarFlatEntity` /
`UniversalFlatEntityFrom`) but previous commands could have `flag ===
undefined` (downcast from the brand since we can't compare with
UpgradeMigrationName like we do with a decorator). That's a
**silent-read** failure mode: compiles fine, comparisons against `flag`
silently always-false, no error surfaces. Probably worth too much risk
for what's a small amount of plumbing?

The eventual full deletion of `flag` (entity field included) is a future
cleanup once we drop cross-upgrade support for versions ≤ 2.6

Note: Not sure if this PR (and even the decorator) is really needed in
the end, seems we need to keep a lot of code in place to handle legacy.
Maybe a simple noop [At]Deprecated is enough @charlesBochet (and a
migration to set the column nullable if that was not the case before +
remove associated constraints)
This commit is contained in:
Weiko
2026-05-21 13:56:06 +02:00
committed by GitHub
parent d192d2d492
commit d13cc7c349
15 changed files with 139 additions and 131 deletions
@@ -0,0 +1,69 @@
import { QueryRunner } from 'typeorm';
import { RegisteredInstanceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-instance-command.decorator';
import { FastInstanceCommand } from 'src/engine/core-modules/upgrade/interfaces/fast-instance-command.interface';
@RegisteredInstanceCommand('2.7.0', 1779600000000)
export class FinalizeRolePermissionFlagCutoverFastInstanceCommand
implements FastInstanceCommand
{
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "core"."rolePermissionFlag"
DROP CONSTRAINT IF EXISTS "IDX_ROLE_PERMISSION_FLAG_FLAG_ROLE_ID_UNIQUE"`,
);
await queryRunner.query(
`ALTER TABLE "core"."rolePermissionFlag"
ALTER COLUMN "permissionFlagId" SET NOT NULL`,
);
await queryRunner.query(
`ALTER TABLE "core"."rolePermissionFlag" DROP COLUMN "flag"`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "core"."rolePermissionFlag"
ADD COLUMN IF NOT EXISTS "flag" varchar`,
);
await queryRunner.query(
`UPDATE "core"."rolePermissionFlag" rolePermissionFlag
SET "flag" = permissionFlag."key"
FROM "core"."permissionFlag" permissionFlag
WHERE permissionFlag."id" = rolePermissionFlag."permissionFlagId"
AND rolePermissionFlag."flag" IS NULL`,
);
await queryRunner.query(
`DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM "core"."rolePermissionFlag"
WHERE "flag" IS NULL
) THEN
RAISE EXCEPTION 'Unable to restore rolePermissionFlag.flag';
END IF;
END $$`,
);
await queryRunner.query(
`ALTER TABLE "core"."rolePermissionFlag"
ALTER COLUMN "flag" SET NOT NULL`,
);
await queryRunner.query(
`ALTER TABLE "core"."rolePermissionFlag"
ADD CONSTRAINT "IDX_ROLE_PERMISSION_FLAG_FLAG_ROLE_ID_UNIQUE"
UNIQUE ("flag", "roleId")`,
);
await queryRunner.query(
`ALTER TABLE "core"."rolePermissionFlag"
ALTER COLUMN "permissionFlagId" DROP NOT NULL`,
);
}
}
@@ -52,6 +52,7 @@ import { AddSubFieldNameToViewSortFastInstanceCommand } from 'src/database/comma
import { DropPostgresCredentialsTableFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-5/2-5-instance-command-fast-1798500000000-drop-postgres-credentials-table';
import { AddRelationTargetFieldMetadataIdToViewFilterFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-6/2-6-instance-command-fast-1798000005000-add-relation-target-field-metadata-id-to-view-filter';
import { AddChannelSyncStageIndexesFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-6/2-6-instance-command-fast-1798000010000-add-channel-sync-stage-indexes';
import { FinalizeRolePermissionFlagCutoverFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-7/2-7-instance-command-fast-1779600000000-finalize-role-permission-flag-cutover';
export const INSTANCE_COMMANDS = [
AddViewFieldGroupIdIndexOnViewFieldFastInstanceCommand,
@@ -106,4 +107,5 @@ export const INSTANCE_COMMANDS = [
DropPostgresCredentialsTableFastInstanceCommand,
AddRelationTargetFieldMetadataIdToViewFilterFastInstanceCommand,
AddChannelSyncStageIndexesFastInstanceCommand,
FinalizeRolePermissionFlagCutoverFastInstanceCommand,
];
@@ -314,7 +314,7 @@ export const ALL_MANY_TO_ONE_METADATA_RELATIONS = {
metadataName: 'permissionFlag',
foreignKey: 'permissionFlagId',
inverseOneToManyProperty: 'rolePermissionFlags',
isNullable: true,
isNullable: false,
universalForeignKey: 'permissionFlagUniversalIdentifier',
},
},
@@ -1,57 +0,0 @@
import {
PermissionFlagType,
SystemPermissionFlag,
} from 'twenty-shared/constants';
import { type FlatPermissionFlag } from 'src/engine/metadata-modules/flat-permission-flag/types/flat-permission-flag.type';
import {
STANDARD_PERMISSION_FLAG_DEFINITIONS,
type StandardPermissionFlagDefinition,
} from 'src/engine/metadata-modules/permission-flag/constants/standard-permission-flag-definitions.constant';
const STANDARD_PERMISSION_FLAG_DEFINITION_BY_KEY: Record<
PermissionFlagType,
StandardPermissionFlagDefinition
> = STANDARD_PERMISSION_FLAG_DEFINITIONS.reduce(
(accumulator, definition) => {
accumulator[definition.key] = definition;
return accumulator;
},
{} as Record<PermissionFlagType, StandardPermissionFlagDefinition>,
);
const SYNTHESIZED_TIMESTAMP = new Date(0).toISOString();
// TODO: Remove this once we have a proper permission flag catalog backfilled
export const synthesizeFlatPermissionFlagFromFlag = ({
flag,
workspaceId,
applicationId,
applicationUniversalIdentifier,
}: {
flag: PermissionFlagType;
workspaceId: string;
applicationId: string;
applicationUniversalIdentifier: string;
}): FlatPermissionFlag => {
const definition = STANDARD_PERMISSION_FLAG_DEFINITION_BY_KEY[flag];
const universalIdentifier = SystemPermissionFlag[flag];
return {
id: universalIdentifier,
universalIdentifier,
workspaceId,
applicationId,
applicationUniversalIdentifier,
key: flag,
label: definition.label,
description: definition.description,
icon: definition.icon,
permissionType: definition.permissionType,
rolePermissionFlagIds: [],
rolePermissionFlagUniversalIdentifiers: [],
createdAt: SYNTHESIZED_TIMESTAMP,
updatedAt: SYNTHESIZED_TIMESTAMP,
};
};
@@ -1,4 +1,3 @@
import { SystemPermissionFlag } from 'twenty-shared/constants';
import { isDefined, removePropertiesFromRecord } from 'twenty-shared/utils';
import {
@@ -43,13 +42,10 @@ export const fromRolePermissionFlagEntityToFlatRolePermissionFlag = ({
);
}
const permissionFlagUniversalIdentifier = isDefined(
rolePermissionFlagEntity.permissionFlagId,
)
? permissionFlagIdToUniversalIdentifierMap.get(
rolePermissionFlagEntity.permissionFlagId,
)
: SystemPermissionFlag[rolePermissionFlagEntity.flag];
const permissionFlagUniversalIdentifier =
permissionFlagIdToUniversalIdentifierMap.get(
rolePermissionFlagEntity.permissionFlagId,
);
if (!isDefined(permissionFlagUniversalIdentifier)) {
throw new FlatEntityMapsException(
@@ -260,8 +260,7 @@ export class PermissionsService {
return rolePermissionFlags.some(
(rolePermissionFlag) =>
(rolePermissionFlag.permissionFlag?.universalIdentifier ??
SystemPermissionFlag[rolePermissionFlag.flag]) ===
rolePermissionFlag.permissionFlag.universalIdentifier ===
permissionFlagUniversalIdentifier,
);
}
@@ -13,6 +13,7 @@ import {
} from 'typeorm';
import { WasIntroducedInUpgrade } from 'src/engine/core-modules/upgrade/decorators/was-introduced-in-upgrade.decorator';
import { WasRemovedInUpgrade } from 'src/engine/core-modules/upgrade/decorators/was-removed-in-upgrade.decorator';
import { WasRenamedInUpgrade } from 'src/engine/core-modules/upgrade/decorators/was-renamed-in-upgrade.decorator';
import { PermissionFlagEntity } from 'src/engine/metadata-modules/permission-flag/permission-flag.entity';
import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity';
@@ -26,7 +27,6 @@ import { SyncableEntity } from 'src/engine/workspace-manager/types/syncable-enti
'2.6.0_RenamePermissionFlagToRolePermissionFlagFastInstanceCommand_1778235340020',
},
])
@Unique('IDX_ROLE_PERMISSION_FLAG_FLAG_ROLE_ID_UNIQUE', ['flag', 'roleId'])
@Unique('IDX_ROLE_PERMISSION_FLAG_PERMISSION_FLAG_ID_ROLE_ID_UNIQUE', [
'permissionFlagId',
'roleId',
@@ -46,6 +46,10 @@ export class RolePermissionFlagEntity extends SyncableEntity {
@JoinColumn({ name: 'roleId' })
role: Relation<RoleEntity>;
@WasRemovedInUpgrade({
upgradeCommandName:
'2.7.0_FinalizeRolePermissionFlagCutoverFastInstanceCommand_1779600000000',
})
@Column({ nullable: false, type: 'varchar' })
flag: PermissionFlagType;
@@ -53,8 +57,8 @@ export class RolePermissionFlagEntity extends SyncableEntity {
upgradeCommandName:
'2.6.0_LinkRolePermissionFlagToPermissionFlagFastInstanceCommand_1778235340022',
})
@Column({ nullable: true, type: 'uuid' })
permissionFlagId: string | null;
@Column({ nullable: false, type: 'uuid' })
permissionFlagId: string;
@ManyToOne(
() => PermissionFlagEntity,
@@ -64,7 +68,7 @@ export class RolePermissionFlagEntity extends SyncableEntity {
},
)
@JoinColumn({ name: 'permissionFlagId' })
permissionFlag: Relation<PermissionFlagEntity> | null;
permissionFlag: Relation<PermissionFlagEntity>;
@CreateDateColumn({ type: 'timestamptz' })
createdAt: Date;
@@ -1,10 +1,7 @@
import { Injectable } from '@nestjs/common';
import { msg } from '@lingui/core/macro';
import {
type PermissionFlagType,
SystemPermissionFlag,
} from 'twenty-shared/constants';
import { type PermissionFlagType } from 'twenty-shared/constants';
import { isDefined } from 'twenty-shared/utils';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
@@ -98,12 +95,10 @@ export class RolePermissionFlagService {
.map(({ permissionFlag }) => permissionFlag?.universalIdentifier)
.filter(isDefined),
);
const getEffectiveUniversalIdentifier = (
pf: FlatRolePermissionFlag,
): string =>
pf.permissionFlagUniversalIdentifier ?? SystemPermissionFlag[pf.flag];
const existingSet = new Set(
currentRolePermissionFlagsForRole.map(getEffectiveUniversalIdentifier),
currentRolePermissionFlagsForRole.map(
(pf) => pf.permissionFlagUniversalIdentifier,
),
);
const { workspaceCustomFlatApplication } =
@@ -132,12 +127,12 @@ export class RolePermissionFlagService {
);
const flatEntityToDelete = currentRolePermissionFlagsForRole.filter(
(pf) => !inputSet.has(getEffectiveUniversalIdentifier(pf)),
(pf) => !inputSet.has(pf.permissionFlagUniversalIdentifier),
);
if (flatEntityToCreate.length === 0 && flatEntityToDelete.length === 0) {
return currentRolePermissionFlagsForRole.filter((pf) =>
inputSet.has(getEffectiveUniversalIdentifier(pf)),
inputSet.has(pf.permissionFlagUniversalIdentifier),
);
}
@@ -3,6 +3,10 @@ import { PermissionFlagType } from 'twenty-shared/constants';
import { type FlatPermissionFlagMaps } from 'src/engine/metadata-modules/flat-permission-flag/types/flat-permission-flag-maps.type';
import { type FlatPermissionFlag } from 'src/engine/metadata-modules/flat-permission-flag/types/flat-permission-flag.type';
import { type FlatRolePermissionFlag } from 'src/engine/metadata-modules/flat-role-permission-flag/types/flat-role-permission-flag.type';
import {
PermissionsException,
PermissionsExceptionCode,
} from 'src/engine/metadata-modules/permissions/permissions.exception';
import { fromFlatRolePermissionFlagToRolePermissionFlagDto } from 'src/engine/metadata-modules/role-permission-flag/utils/from-flat-role-permission-flag-to-role-permission-flag-dto.util';
const buildFlatRolePermissionFlag = (
@@ -11,11 +15,7 @@ const buildFlatRolePermissionFlag = (
({
id: 'role-permission-flag-id',
roleId: 'role-id',
workspaceId: 'workspace-id',
applicationId: 'application-id',
applicationUniversalIdentifier: 'application-universal-id',
permissionFlagUniversalIdentifier: 'permission-flag-universal-id',
flag: PermissionFlagType.WORKSPACE,
...overrides,
}) as FlatRolePermissionFlag;
@@ -60,8 +60,8 @@ describe('fromFlatRolePermissionFlagToRolePermissionFlagDto', () => {
});
});
it('falls back to the legacy flag column when the catalog is missing the entry', () => {
expect(
it('throws a permissions exception when the permission flag is missing', () => {
expect(() =>
fromFlatRolePermissionFlagToRolePermissionFlagDto(
buildFlatRolePermissionFlag(),
{
@@ -70,10 +70,11 @@ describe('fromFlatRolePermissionFlagToRolePermissionFlagDto', () => {
universalIdentifiersByApplicationId: {},
},
),
).toEqual({
id: 'role-permission-flag-id',
roleId: 'role-id',
flag: PermissionFlagType.WORKSPACE,
});
).toThrow(
new PermissionsException(
'Permission flag permission-flag-universal-id not found',
PermissionsExceptionCode.PERMISSION_NOT_FOUND,
),
);
});
});
@@ -2,31 +2,28 @@ import { PermissionFlagType } from 'twenty-shared/constants';
import { isDefined } from 'twenty-shared/utils';
import { type FlatPermissionFlagMaps } from 'src/engine/metadata-modules/flat-permission-flag/types/flat-permission-flag-maps.type';
import { synthesizeFlatPermissionFlagFromFlag } from 'src/engine/metadata-modules/flat-permission-flag/utils/synthesize-flat-permission-flag-from-flag.util';
import { type FlatRolePermissionFlag } from 'src/engine/metadata-modules/flat-role-permission-flag/types/flat-role-permission-flag.type';
import {
PermissionsException,
PermissionsExceptionCode,
} from 'src/engine/metadata-modules/permissions/permissions.exception';
import { type RolePermissionFlagDTO } from 'src/engine/metadata-modules/role-permission-flag/dtos/role-permission-flag.dto';
export const fromFlatRolePermissionFlagToRolePermissionFlagDto = (
flatRolePermissionFlag: FlatRolePermissionFlag,
flatPermissionFlagMaps: FlatPermissionFlagMaps,
): RolePermissionFlagDTO => {
const catalogPermissionFlag = isDefined(
flatRolePermissionFlag.permissionFlagUniversalIdentifier,
)
? flatPermissionFlagMaps.byUniversalIdentifier[
flatRolePermissionFlag.permissionFlagUniversalIdentifier
]
: undefined;
const permissionFlag =
flatPermissionFlagMaps.byUniversalIdentifier[
flatRolePermissionFlag.permissionFlagUniversalIdentifier
];
const permissionFlag = isDefined(catalogPermissionFlag)
? catalogPermissionFlag
: synthesizeFlatPermissionFlagFromFlag({
flag: flatRolePermissionFlag.flag,
workspaceId: flatRolePermissionFlag.workspaceId,
applicationId: flatRolePermissionFlag.applicationId,
applicationUniversalIdentifier:
flatRolePermissionFlag.applicationUniversalIdentifier,
});
if (!isDefined(permissionFlag)) {
throw new PermissionsException(
`Permission flag ${flatRolePermissionFlag.permissionFlagUniversalIdentifier} not found`,
PermissionsExceptionCode.PERMISSION_NOT_FOUND,
);
}
return {
id: flatRolePermissionFlag.id,
@@ -283,8 +283,7 @@ export class WorkspaceRolesPermissionsCacheService extends WorkspaceCacheProvide
const hasPermissionFromSettingPermissions = isDefined(
rolePermissionFlags.find(
(rolePermissionFlag) =>
(rolePermissionFlag.permissionFlag?.universalIdentifier ??
SystemPermissionFlag[rolePermissionFlag.flag]) ===
rolePermissionFlag.permissionFlag.universalIdentifier ===
permissionFlagUniversalIdentifier,
),
);
@@ -24,8 +24,7 @@ export const fromRoleEntityToRoleDto = (role: RoleEntity): RoleDTO => {
permissionFlags: role.rolePermissionFlags?.map((rolePermissionFlag) => ({
id: rolePermissionFlag.id,
roleId: rolePermissionFlag.roleId,
flag: (rolePermissionFlag.permissionFlag?.key ??
rolePermissionFlag.flag) as PermissionFlagType,
flag: rolePermissionFlag.permissionFlag.key as PermissionFlagType,
})),
objectPermissions: role.objectPermissions,
fieldPermissions: role.fieldPermissions,
@@ -151,5 +151,7 @@ describe('UpgradeAwareEntityMetadataAdapter', () => {
expect(visibleColumn.isSelect).toBe(true);
expect(visibleColumn.isInsert).toBe(true);
expect(visibleColumn.isUpdate).toBe(true);
expect(metadata.columns).toEqual([visibleColumn]);
});
});
@@ -23,6 +23,7 @@ type EntityMetadataSnapshot = {
tableName: string;
tablePath: string;
givenTableName: string | undefined;
canonicalColumns: ReadonlyArray<ColumnMetadata>;
columnDatabaseNamesByPropertyName: ReadonlyMap<string, string>;
columnSelectByPropertyName: ReadonlyMap<string, boolean>;
columnInsertByPropertyName: ReadonlyMap<string, boolean>;
@@ -135,6 +136,7 @@ export class UpgradeAwareEntityMetadataAdapter implements OnModuleInit {
tableName: metadata.tableName,
tablePath: metadata.tablePath,
givenTableName: metadata.givenTableName,
canonicalColumns: [...metadata.columns],
columnDatabaseNamesByPropertyName,
columnSelectByPropertyName,
columnInsertByPropertyName,
@@ -277,9 +279,15 @@ export class UpgradeAwareEntityMetadataAdapter implements OnModuleInit {
metadata.givenTableName = resolved.effectiveTableName;
}
metadata.columns = [...snapshot.canonicalColumns];
for (const column of metadata.columns) {
this.applyColumnShape({ column, snapshot, resolved });
}
metadata.columns = metadata.columns.filter(
(column) => !resolved.hiddenPropertyNames.has(column.propertyName),
);
}
private applyColumnShape({
@@ -80,24 +80,18 @@ export class FlatRolePermissionFlagValidatorService {
}
}
if (
isDefined(
const referencedPermissionFlag = findFlatEntityByUniversalIdentifier({
universalIdentifier:
flatRolePermissionFlagToValidate.permissionFlagUniversalIdentifier,
)
) {
const referencedPermissionFlag = findFlatEntityByUniversalIdentifier({
universalIdentifier:
flatRolePermissionFlagToValidate.permissionFlagUniversalIdentifier,
flatEntityMaps: flatPermissionFlagMaps,
});
flatEntityMaps: flatPermissionFlagMaps,
});
if (!isDefined(referencedPermissionFlag)) {
validationResult.errors.push({
code: PermissionsExceptionCode.INVALID_SETTING,
message: t`Permission flag not found`,
userFriendlyMessage: msg`Invalid permission setting`,
});
}
if (!isDefined(referencedPermissionFlag)) {
validationResult.errors.push({
code: PermissionsExceptionCode.INVALID_SETTING,
message: t`Permission flag not found`,
userFriendlyMessage: msg`Invalid permission setting`,
});
}
const duplicateForSameRole = Object.values(