Fix overridable entities logic for SSE (#19433)

## Context
SSE metadata events for overridable entities (viewField, viewFieldGroup,
pageLayoutWidget, pageLayoutTab) were sending raw flat entity data
without resolving overrides into base properties or filtering isActive:
false entities. This caused the frontend to display stale/incorrect
values (e.g., a hidden viewField still appearing visible).

## Implementation
Add a sanitization step in
MetadataEventPublisher.enrichMetadataEventBatch that resolves overrides
and converts isActive transitions into the appropriate event types
(deactivated -> delete, reactivated -> create), matching what GraphQL
resolvers already do
This commit is contained in:
Weiko
2026-04-08 13:53:55 +02:00
committed by GitHub
parent 9a58f3d459
commit 8aa208fc93
5 changed files with 670 additions and 55 deletions
@@ -6,6 +6,7 @@ import { OBJECT_METADATA_STANDARD_OVERRIDES_PROPERTIES } from 'src/engine/metada
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
import { NavigationMenuItemRecordIdentifierService } from 'src/engine/metadata-modules/navigation-menu-item/services/navigation-menu-item-record-identifier.service';
import { type MetadataEventBatch } from 'src/engine/subscriptions/metadata-event/types/metadata-event-batch.type';
import { sanitizeOverridableEntityEventBatch } from 'src/engine/subscriptions/metadata-event/utils/sanitize-overridable-entity-event-batch.util';
import { WorkspaceEventBroadcaster } from 'src/engine/subscriptions/workspace-event-broadcaster/workspace-event-broadcaster.service';
import { enrichFieldMetadataEventWithRelations } from 'src/engine/subscriptions/metadata-event/utils/enrich-field-metadata-event-with-relations.util';
@@ -54,7 +55,7 @@ export class MetadataEventPublisher {
metadataEventBatch as MetadataEventBatch<'objectMetadata'>,
);
default:
return metadataEventBatch;
return sanitizeOverridableEntityEventBatch(metadataEventBatch);
}
}
@@ -0,0 +1,378 @@
import { type MetadataEventBatch } from 'src/engine/subscriptions/metadata-event/types/metadata-event-batch.type';
import { sanitizeOverridableEntityEventBatch } from 'src/engine/subscriptions/metadata-event/utils/sanitize-overridable-entity-event-batch.util';
const makeViewFieldRecord = (
overrides?: Partial<Record<string, unknown>>,
): Record<string, unknown> => ({
id: 'vf-1',
workspaceId: 'ws-1',
applicationId: 'app-1',
universalIdentifier: 'uid-1',
isVisible: true,
size: 200,
position: 0,
aggregateOperation: null,
viewFieldGroupId: null,
fieldMetadataId: 'fm-1',
viewId: 'view-1',
isActive: true,
overrides: null,
createdAt: '2025-01-01',
updatedAt: '2025-01-01',
deletedAt: null,
...overrides,
});
const makeBatch = (
metadataName: string,
events: Record<string, unknown>[],
): MetadataEventBatch =>
({
name: `metadata.${metadataName}.updated`,
workspaceId: 'ws-1',
metadataName,
type: 'updated',
events,
}) as MetadataEventBatch;
describe('sanitizeOverridableEntityEventBatch', () => {
describe('non-overridable entity (pass-through)', () => {
it('should return the batch unchanged for entities without overrides config', () => {
const batch = makeBatch('view', [
{
type: 'updated',
metadataName: 'view',
recordId: 'v-1',
properties: {
updatedFields: ['name'],
diff: { name: { before: 'old', after: 'new' } },
before: { id: 'v-1', name: 'old' },
after: { id: 'v-1', name: 'new' },
},
},
]);
const result = sanitizeOverridableEntityEventBatch(batch);
expect(result).toBe(batch);
});
});
describe('override resolution', () => {
it('should resolve overrides into base properties and strip overrides/isActive', () => {
const after = makeViewFieldRecord({
isVisible: true,
overrides: { isVisible: false },
});
const batch = makeBatch('viewField', [
{
type: 'created',
metadataName: 'viewField',
recordId: 'vf-1',
properties: { after },
},
]);
const result = sanitizeOverridableEntityEventBatch(batch);
const createdRecord = (
result.events[0] as { properties: { after: Record<string, unknown> } }
).properties.after;
expect(createdRecord.isVisible).toBe(false);
expect(createdRecord).not.toHaveProperty('overrides');
expect(createdRecord).not.toHaveProperty('isActive');
});
it('should strip overrides and isActive even when overrides is null', () => {
const after = makeViewFieldRecord({ overrides: null });
const batch = makeBatch('viewField', [
{
type: 'created',
metadataName: 'viewField',
recordId: 'vf-1',
properties: { after },
},
]);
const result = sanitizeOverridableEntityEventBatch(batch);
const createdRecord = (
result.events[0] as { properties: { after: Record<string, unknown> } }
).properties.after;
expect(createdRecord.isVisible).toBe(true);
expect(createdRecord).not.toHaveProperty('overrides');
expect(createdRecord).not.toHaveProperty('isActive');
});
it('should resolve multiple override properties', () => {
const after = makeViewFieldRecord({
isVisible: true,
size: 200,
position: 0,
overrides: { isVisible: false, size: 300, position: 5 },
});
const batch = makeBatch('viewField', [
{
type: 'created',
metadataName: 'viewField',
recordId: 'vf-1',
properties: { after },
},
]);
const result = sanitizeOverridableEntityEventBatch(batch);
const createdRecord = (
result.events[0] as { properties: { after: Record<string, unknown> } }
).properties.after;
expect(createdRecord.isVisible).toBe(false);
expect(createdRecord.size).toBe(300);
expect(createdRecord.position).toBe(5);
});
it('should resolve overrides on both before and after for update events', () => {
const before = makeViewFieldRecord({
isVisible: true,
overrides: null,
});
const after = makeViewFieldRecord({
isVisible: true,
overrides: { isVisible: false },
});
const batch = makeBatch('viewField', [
{
type: 'updated',
metadataName: 'viewField',
recordId: 'vf-1',
properties: {
updatedFields: ['overrides'],
diff: {},
before,
after,
},
},
]);
const result = sanitizeOverridableEntityEventBatch(batch);
const event = result.events[0] as {
properties: {
before: Record<string, unknown>;
after: Record<string, unknown>;
};
};
expect(event.properties.before.isVisible).toBe(true);
expect(event.properties.after.isVisible).toBe(false);
});
it('should resolve overrides on before for delete events', () => {
const before = makeViewFieldRecord({
isVisible: true,
overrides: { isVisible: false },
});
const batch = makeBatch('viewField', [
{
type: 'deleted',
metadataName: 'viewField',
recordId: 'vf-1',
properties: { before },
},
]);
const result = sanitizeOverridableEntityEventBatch(batch);
const deletedRecord = (
result.events[0] as { properties: { before: Record<string, unknown> } }
).properties.before;
expect(deletedRecord.isVisible).toBe(false);
expect(deletedRecord).not.toHaveProperty('overrides');
expect(deletedRecord).not.toHaveProperty('isActive');
});
});
describe('isActive transitions', () => {
it('should drop create events when isActive is false', () => {
const after = makeViewFieldRecord({ isActive: false });
const batch = makeBatch('viewField', [
{
type: 'created',
metadataName: 'viewField',
recordId: 'vf-1',
properties: { after },
},
]);
const result = sanitizeOverridableEntityEventBatch(batch);
expect(result.events).toHaveLength(0);
});
it('should convert update to delete when entity is deactivated', () => {
const before = makeViewFieldRecord({ isActive: true });
const after = makeViewFieldRecord({ isActive: false });
const batch = makeBatch('viewField', [
{
type: 'updated',
metadataName: 'viewField',
recordId: 'vf-1',
properties: {
updatedFields: ['isActive'],
diff: {},
before,
after,
},
},
]);
const result = sanitizeOverridableEntityEventBatch(batch);
expect(result.events).toHaveLength(1);
expect(result.events[0].type).toBe('deleted');
expect(
(
result.events[0] as {
properties: { before: Record<string, unknown> };
}
).properties.before,
).not.toHaveProperty('isActive');
});
it('should convert update to create when entity is reactivated', () => {
const before = makeViewFieldRecord({ isActive: false });
const after = makeViewFieldRecord({
isActive: true,
overrides: { isVisible: false },
});
const batch = makeBatch('viewField', [
{
type: 'updated',
metadataName: 'viewField',
recordId: 'vf-1',
properties: {
updatedFields: ['isActive'],
diff: {},
before,
after,
},
},
]);
const result = sanitizeOverridableEntityEventBatch(batch);
expect(result.events).toHaveLength(1);
expect(result.events[0].type).toBe('created');
const createdRecord = (
result.events[0] as { properties: { after: Record<string, unknown> } }
).properties.after;
expect(createdRecord.isVisible).toBe(false);
expect(createdRecord).not.toHaveProperty('overrides');
});
it('should drop update events when both before and after are inactive', () => {
const before = makeViewFieldRecord({ isActive: false });
const after = makeViewFieldRecord({
isActive: false,
overrides: { isVisible: false },
});
const batch = makeBatch('viewField', [
{
type: 'updated',
metadataName: 'viewField',
recordId: 'vf-1',
properties: {
updatedFields: ['overrides'],
diff: {},
before,
after,
},
},
]);
const result = sanitizeOverridableEntityEventBatch(batch);
expect(result.events).toHaveLength(0);
});
it('should keep update events when both before and after are active', () => {
const before = makeViewFieldRecord({ isActive: true });
const after = makeViewFieldRecord({ isActive: true, size: 300 });
const batch = makeBatch('viewField', [
{
type: 'updated',
metadataName: 'viewField',
recordId: 'vf-1',
properties: {
updatedFields: ['size'],
diff: {},
before,
after,
},
},
]);
const result = sanitizeOverridableEntityEventBatch(batch);
expect(result.events).toHaveLength(1);
expect(result.events[0].type).toBe('updated');
});
});
describe('pageLayoutWidget (another overridable entity)', () => {
it('should resolve overrides for pageLayoutWidget', () => {
const after = {
id: 'plw-1',
workspaceId: 'ws-1',
applicationId: 'app-1',
universalIdentifier: 'uid-1',
title: 'Original',
type: 'FIELDS',
position: '{}',
gridPosition: '{}',
configuration: '{}',
conditionalDisplay: null,
pageLayoutTabId: 'plt-1',
isActive: true,
overrides: { title: 'Overridden Title' },
createdAt: '2025-01-01',
updatedAt: '2025-01-01',
deletedAt: null,
};
const batch = makeBatch('pageLayoutWidget', [
{
type: 'created',
metadataName: 'pageLayoutWidget',
recordId: 'plw-1',
properties: { after },
},
]);
const result = sanitizeOverridableEntityEventBatch(batch);
const createdRecord = (
result.events[0] as { properties: { after: Record<string, unknown> } }
).properties.after;
expect(createdRecord.title).toBe('Overridden Title');
expect(createdRecord).not.toHaveProperty('overrides');
expect(createdRecord).not.toHaveProperty('isActive');
});
});
});
@@ -0,0 +1,116 @@
import { type AllMetadataName } from 'twenty-shared/metadata';
import { isDefined } from 'twenty-shared/utils';
import { type MetadataEventBatch } from 'src/engine/subscriptions/metadata-event/types/metadata-event-batch.type';
import {
type CreateMetadataEvent,
type DeleteMetadataEvent,
type MetadataEvent,
type UpdateMetadataEvent,
} from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/types/metadata-event';
const OVERRIDABLE_ENTITY_METADATA_NAMES = new Set<AllMetadataName>([
'viewField',
'viewFieldGroup',
'pageLayoutTab',
'pageLayoutWidget',
]);
const resolveRecordOverrides = (
record: Record<string, unknown>,
): Record<string, unknown> => {
const { overrides, isActive, ...base } = record;
if (!isDefined(overrides)) {
return base;
}
return { ...base, ...(overrides as Record<string, unknown>) };
};
const sanitizeCreatedEvent = (
event: CreateMetadataEvent<AllMetadataName>,
): MetadataEvent | null => {
const after = event.properties.after as Record<string, unknown>;
if (after.isActive === false) {
return null;
}
return {
...event,
properties: { after: resolveRecordOverrides(after) },
} as typeof event;
};
const sanitizeDeletedEvent = (
event: DeleteMetadataEvent<AllMetadataName>,
): MetadataEvent => {
const before = event.properties.before as Record<string, unknown>;
return {
...event,
properties: { before: resolveRecordOverrides(before) },
} as typeof event;
};
const sanitizeUpdatedEvent = (
event: UpdateMetadataEvent<AllMetadataName>,
): MetadataEvent | null => {
const before = event.properties.before as Record<string, unknown>;
const after = event.properties.after as Record<string, unknown>;
if (before.isActive === false && after.isActive === false) {
return null;
}
if (after.isActive === false) {
return {
type: 'deleted',
metadataName: event.metadataName,
recordId: event.recordId,
properties: { before: resolveRecordOverrides(before) },
} as MetadataEvent;
}
if (before.isActive === false) {
return {
type: 'created',
metadataName: event.metadataName,
recordId: event.recordId,
properties: { after: resolveRecordOverrides(after) },
} as MetadataEvent;
}
return {
...event,
properties: {
...event.properties,
before: resolveRecordOverrides(before),
after: resolveRecordOverrides(after),
},
} as typeof event;
};
export const sanitizeOverridableEntityEventBatch = (
metadataEventBatch: MetadataEventBatch,
): MetadataEventBatch => {
if (!OVERRIDABLE_ENTITY_METADATA_NAMES.has(metadataEventBatch.metadataName)) {
return metadataEventBatch;
}
const events = metadataEventBatch.events
.map((event) => {
switch (event.type) {
case 'created':
return sanitizeCreatedEvent(event);
case 'updated':
return sanitizeUpdatedEvent(event);
case 'deleted':
return sanitizeDeletedEvent(event);
}
})
.filter(isDefined);
return { ...metadataEventBatch, events };
};