Add SSE for metadata and plug front components (#17998)

Create the necessary tooling to listen to metadata events and plug it to
the front components. Now we have a hot reload like experience when we
edit a component in an app.

## Backend

- Split `EventWithQueryIds` into `ObjectRecordEventWithQueryIds` and
`MetadataEventWithQueryIds`
- Publish metadata event batches to active SSE streams in
`MetadataEventsToDbListener`

## Frontend

- Create a metadata event dispatching pipeline: SSE metadata events are
grouped by metadata name, transformed into
`MetadataOperationBrowserEventDetail` objects, and dispatched as browser
`CustomEvents`
- Add `useListenToMetadataOperationBrowserEvent` hook for consuming
metadata operation events filtered by metadata name and operation type
- Rename `useListenToObjectRecordEventsForQuery` to
`useListenToEventsForQuery`, now accepting both
`RecordGqlOperationSignature` and `MetadataGqlOperationSignature`
- Implement `useOnFrontComponentUpdated` which subscribes to front
component metadata events and updates the Apollo cache when the
component is modified
- Add `builtComponentChecksum` to the front component query and appends
it to the component URL for browser cache invalidation
This commit is contained in:
Raphaël Bosi
2026-02-18 12:26:20 +01:00
committed by GitHub
parent e3753bf822
commit 2455c859b4
76 changed files with 1636 additions and 173 deletions
@@ -0,0 +1,512 @@
import { isMetadataRecordMatchingFilter } from 'src/engine/workspace-event-emitter/utils/is-metadata-record-matching-filter.util';
const record = {
id: '1',
name: 'Test Object',
label: 'testObject',
isActive: true,
count: 42,
};
describe('isMetadataRecordMatchingFilter', () => {
describe('empty filters', () => {
it('should match any record when filter is empty', () => {
expect(isMetadataRecordMatchingFilter({ record, filter: {} })).toBe(true);
});
});
describe('and filter', () => {
it('should match when all sub-filters match', () => {
const filter = {
and: [{ name: { eq: 'Test Object' } }, { isActive: { eq: true } }],
};
expect(isMetadataRecordMatchingFilter({ record, filter })).toBe(true);
});
it('should not match when any sub-filter fails', () => {
const filter = {
and: [{ name: { eq: 'Test Object' } }, { isActive: { eq: false } }],
};
expect(isMetadataRecordMatchingFilter({ record, filter })).toBe(false);
});
it('should match when and array is empty', () => {
expect(
isMetadataRecordMatchingFilter({
record,
filter: { and: [] },
}),
).toBe(true);
});
it('should throw when and value is not an array', () => {
expect(() =>
isMetadataRecordMatchingFilter({
record,
filter: { and: 'invalid' } as any,
}),
).toThrow('Unexpected value for "and" filter');
});
});
describe('or filter', () => {
it('should match when any sub-filter matches', () => {
const filter = {
or: [{ name: { eq: 'Wrong Name' } }, { name: { eq: 'Test Object' } }],
};
expect(isMetadataRecordMatchingFilter({ record, filter })).toBe(true);
});
it('should not match when no sub-filter matches', () => {
const filter = {
or: [{ name: { eq: 'Wrong' } }, { name: { eq: 'Also Wrong' } }],
};
expect(isMetadataRecordMatchingFilter({ record, filter })).toBe(false);
});
it('should match when or array is empty', () => {
expect(
isMetadataRecordMatchingFilter({
record,
filter: { or: [] },
}),
).toBe(true);
});
it('should treat or with an object as an and', () => {
const filter = {
or: { name: { eq: 'Test Object' } },
};
expect(isMetadataRecordMatchingFilter({ record, filter })).toBe(true);
});
it('should throw when or value is neither array nor object', () => {
expect(() =>
isMetadataRecordMatchingFilter({
record,
filter: { or: 'invalid' } as any,
}),
).toThrow('Unexpected value for "or" filter');
});
});
describe('not filter', () => {
it('should negate a matching filter', () => {
const filter = { not: { name: { eq: 'Test Object' } } };
expect(isMetadataRecordMatchingFilter({ record, filter })).toBe(false);
});
it('should negate a non-matching filter', () => {
const filter = { not: { name: { eq: 'Wrong' } } };
expect(isMetadataRecordMatchingFilter({ record, filter })).toBe(true);
});
it('should match when not contains an empty object', () => {
expect(
isMetadataRecordMatchingFilter({
record,
filter: { not: {} },
}),
).toBe(true);
});
});
describe('implicit and (multi-key filter)', () => {
it('should treat multiple keys as an implicit AND', () => {
const filter = {
name: { eq: 'Test Object' },
isActive: { eq: true },
};
expect(isMetadataRecordMatchingFilter({ record, filter })).toBe(true);
});
it('should fail if any key in implicit AND does not match', () => {
const filter = {
name: { eq: 'Test Object' },
isActive: { eq: false },
};
expect(isMetadataRecordMatchingFilter({ record, filter })).toBe(false);
});
});
describe('eq operator', () => {
it('should match equal string values', () => {
expect(
isMetadataRecordMatchingFilter({
record,
filter: { name: { eq: 'Test Object' } },
}),
).toBe(true);
});
it('should not match different string values', () => {
expect(
isMetadataRecordMatchingFilter({
record,
filter: { name: { eq: 'Other' } },
}),
).toBe(false);
});
it('should match equal boolean values', () => {
expect(
isMetadataRecordMatchingFilter({
record,
filter: { isActive: { eq: true } },
}),
).toBe(true);
});
});
describe('neq operator', () => {
it('should match when values are different', () => {
expect(
isMetadataRecordMatchingFilter({
record,
filter: { name: { neq: 'Other' } },
}),
).toBe(true);
});
it('should not match when values are equal', () => {
expect(
isMetadataRecordMatchingFilter({
record,
filter: { name: { neq: 'Test Object' } },
}),
).toBe(false);
});
});
describe('in operator', () => {
it('should match when value is in the array', () => {
expect(
isMetadataRecordMatchingFilter({
record,
filter: { name: { in: ['Test Object', 'Other'] } },
}),
).toBe(true);
});
it('should not match when value is not in the array', () => {
expect(
isMetadataRecordMatchingFilter({
record,
filter: { name: { in: ['A', 'B'] } },
}),
).toBe(false);
});
it('should return false when in value is not an array', () => {
expect(
isMetadataRecordMatchingFilter({
record,
filter: { name: { in: 'not-array' } } as any,
}),
).toBe(false);
});
});
describe('is operator', () => {
it('should match NULL for undefined values', () => {
expect(
isMetadataRecordMatchingFilter({
record: { ...record, optional: undefined },
filter: { optional: { is: 'NULL' } },
}),
).toBe(true);
});
it('should not match NULL for defined values', () => {
expect(
isMetadataRecordMatchingFilter({
record,
filter: { name: { is: 'NULL' } },
}),
).toBe(false);
});
it('should match NOT_NULL for defined values', () => {
expect(
isMetadataRecordMatchingFilter({
record,
filter: { name: { is: 'NOT_NULL' } },
}),
).toBe(true);
});
it('should not match NOT_NULL for undefined values', () => {
expect(
isMetadataRecordMatchingFilter({
record: { ...record, optional: undefined },
filter: { optional: { is: 'NOT_NULL' } },
}),
).toBe(false);
});
});
describe('like operator', () => {
it('should match with wildcard prefix', () => {
expect(
isMetadataRecordMatchingFilter({
record,
filter: { name: { like: '%Object' } },
}),
).toBe(true);
});
it('should match with wildcard suffix', () => {
expect(
isMetadataRecordMatchingFilter({
record,
filter: { name: { like: 'Test%' } },
}),
).toBe(true);
});
it('should match with wildcards on both sides', () => {
expect(
isMetadataRecordMatchingFilter({
record,
filter: { name: { like: '%est Obj%' } },
}),
).toBe(true);
});
it('should not match when pattern does not match', () => {
expect(
isMetadataRecordMatchingFilter({
record,
filter: { name: { like: 'wrong%' } },
}),
).toBe(false);
});
it('should escape regex special characters in pattern', () => {
const specialRecord = { ...record, name: 'foo.bar' };
expect(
isMetadataRecordMatchingFilter({
record: specialRecord,
filter: { name: { like: 'foo.bar' } },
}),
).toBe(true);
// A dot in the pattern should NOT match any character
expect(
isMetadataRecordMatchingFilter({
record: { ...record, name: 'fooXbar' },
filter: { name: { like: 'foo.bar' } },
}),
).toBe(false);
});
it('should return false for non-string values', () => {
expect(
isMetadataRecordMatchingFilter({
record,
filter: { count: { like: '42' } },
}),
).toBe(false);
});
});
describe('ilike operator', () => {
it('should match case-insensitively', () => {
expect(
isMetadataRecordMatchingFilter({
record,
filter: { name: { ilike: '%test object%' } },
}),
).toBe(true);
});
it('should escape regex special characters', () => {
const specialRecord = { ...record, name: 'foo(bar)' };
expect(
isMetadataRecordMatchingFilter({
record: specialRecord,
filter: { name: { ilike: 'FOO(BAR)' } },
}),
).toBe(true);
});
it('should return false for non-string values', () => {
expect(
isMetadataRecordMatchingFilter({
record,
filter: { count: { ilike: '42' } },
}),
).toBe(false);
});
});
describe('gt operator', () => {
it('should match when value is greater', () => {
expect(
isMetadataRecordMatchingFilter({
record,
filter: { count: { gt: 40 } },
}),
).toBe(true);
});
it('should not match when value is equal', () => {
expect(
isMetadataRecordMatchingFilter({
record,
filter: { count: { gt: 42 } },
}),
).toBe(false);
});
it('should not match when value is less', () => {
expect(
isMetadataRecordMatchingFilter({
record,
filter: { count: { gt: 50 } },
}),
).toBe(false);
});
});
describe('gte operator', () => {
it('should match when value is greater or equal', () => {
expect(
isMetadataRecordMatchingFilter({
record,
filter: { count: { gte: 42 } },
}),
).toBe(true);
});
it('should not match when value is less', () => {
expect(
isMetadataRecordMatchingFilter({
record,
filter: { count: { gte: 43 } },
}),
).toBe(false);
});
});
describe('lt operator', () => {
it('should match when value is less', () => {
expect(
isMetadataRecordMatchingFilter({
record,
filter: { count: { lt: 50 } },
}),
).toBe(true);
});
it('should not match when value is equal', () => {
expect(
isMetadataRecordMatchingFilter({
record,
filter: { count: { lt: 42 } },
}),
).toBe(false);
});
});
describe('lte operator', () => {
it('should match when value is less or equal', () => {
expect(
isMetadataRecordMatchingFilter({
record,
filter: { count: { lte: 42 } },
}),
).toBe(true);
});
it('should not match when value is greater', () => {
expect(
isMetadataRecordMatchingFilter({
record,
filter: { count: { lte: 41 } },
}),
).toBe(false);
});
});
describe('unknown operator', () => {
it('should throw for unsupported operators', () => {
expect(() =>
isMetadataRecordMatchingFilter({
record,
filter: { name: { unknownOp: 'value' } } as any,
}),
).toThrow('Unsupported filter operator');
});
});
describe('nested logical operators', () => {
it('should handle deeply nested and/or/not', () => {
const filter = {
and: [
{
or: [
{ name: { eq: 'Wrong' } },
{ not: { isActive: { eq: false } } },
],
},
{ count: { gte: 40 } },
],
};
expect(isMetadataRecordMatchingFilter({ record, filter })).toBe(true);
});
it('should handle not with nested and', () => {
const filter = {
not: {
and: [{ name: { eq: 'Test Object' } }, { count: { gt: 100 } }],
},
};
// name matches but count > 100 fails, so AND = false, NOT = true
expect(isMetadataRecordMatchingFilter({ record, filter })).toBe(true);
});
});
describe('edge cases', () => {
it('should skip non-object field filters', () => {
// When fieldFilter is a primitive, it is skipped (returns true)
expect(
isMetadataRecordMatchingFilter({
record,
filter: { name: 'Test Object' },
}),
).toBe(true);
});
it('should skip undefined field filters', () => {
expect(
isMetadataRecordMatchingFilter({
record,
filter: { name: undefined },
}),
).toBe(true);
});
it('should handle records with missing fields', () => {
expect(
isMetadataRecordMatchingFilter({
record: { id: '1' },
filter: { name: { eq: 'Test' } },
}),
).toBe(false);
});
});
});
@@ -0,0 +1,198 @@
import { isObject } from '@sniptt/guards';
import { isDefined } from 'twenty-shared/utils';
type MetadataFilter = Record<string, unknown>;
type MetadataScalarFilter = {
is?: 'NULL' | 'NOT_NULL';
eq?: unknown;
neq?: unknown;
in?: unknown[];
like?: string;
ilike?: string;
gt?: number;
gte?: number;
lt?: number;
lte?: number;
};
const isEmptyFilter = (filter: MetadataFilter): boolean =>
Object.keys(filter).length === 0;
const isAndFilter = (filter: MetadataFilter): boolean =>
'and' in filter && isDefined(filter.and);
const isOrFilter = (filter: MetadataFilter): boolean =>
'or' in filter && isDefined(filter.or);
const isNotFilter = (filter: MetadataFilter): boolean =>
'not' in filter && isDefined(filter.not);
const isImplicitAndFilter = (filter: MetadataFilter): boolean =>
Object.keys(filter).length > 1;
const escapeRegExp = (str: string): string =>
str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
export const isMetadataRecordMatchingFilter = ({
record,
filter,
}: {
record: Record<string, unknown>;
filter: MetadataFilter;
}): boolean => {
if (isEmptyFilter(filter)) {
return true;
}
if (isImplicitAndFilter(filter)) {
return Object.entries(filter).every(([key, value]) =>
isMetadataRecordMatchingFilter({
record,
filter: { [key]: value },
}),
);
}
if (isAndFilter(filter)) {
const andValue = filter.and;
if (!Array.isArray(andValue)) {
throw new Error(
'Unexpected value for "and" filter: ' + JSON.stringify(andValue),
);
}
return (
andValue.length === 0 ||
andValue.every((subFilter: MetadataFilter) =>
isMetadataRecordMatchingFilter({ record, filter: subFilter }),
)
);
}
if (isOrFilter(filter)) {
const orValue = filter.or;
if (Array.isArray(orValue)) {
return (
orValue.length === 0 ||
orValue.some((subFilter: MetadataFilter) =>
isMetadataRecordMatchingFilter({ record, filter: subFilter }),
)
);
}
if (isObject(orValue)) {
return isMetadataRecordMatchingFilter({
record,
filter: orValue as MetadataFilter,
});
}
throw new Error(
'Unexpected value for "or" filter: ' + JSON.stringify(orValue),
);
}
if (isNotFilter(filter)) {
const notValue = filter.not;
if (isObject(notValue) && isEmptyFilter(notValue as MetadataFilter)) {
return true;
}
return !isMetadataRecordMatchingFilter({
record,
filter: notValue as MetadataFilter,
});
}
return Object.entries(filter).every(([fieldName, fieldFilter]) => {
if (!isDefined(fieldFilter) || !isObject(fieldFilter)) {
return true;
}
const recordValue = record[fieldName];
return isScalarValueMatchingFilter(
recordValue,
fieldFilter as MetadataScalarFilter,
);
});
};
const isScalarValueMatchingFilter = (
value: unknown,
fieldFilter: MetadataScalarFilter,
): boolean => {
if ('is' in fieldFilter) {
if (fieldFilter.is === 'NULL') {
return !isDefined(value);
}
return isDefined(value);
}
if ('eq' in fieldFilter) {
return value === fieldFilter.eq;
}
if ('neq' in fieldFilter) {
return value !== fieldFilter.neq;
}
if ('in' in fieldFilter) {
if (!Array.isArray(fieldFilter.in)) {
return false;
}
return fieldFilter.in.includes(value);
}
if ('like' in fieldFilter) {
if (typeof value !== 'string') {
return false;
}
const pattern = String(fieldFilter.like)
.split('%')
.map(escapeRegExp)
.join('.*');
return new RegExp(`^${pattern}$`).test(value);
}
if ('ilike' in fieldFilter) {
if (typeof value !== 'string') {
return false;
}
const pattern = String(fieldFilter.ilike)
.split('%')
.map(escapeRegExp)
.join('.*');
return new RegExp(`^${pattern}$`, 'i').test(value);
}
if ('gt' in fieldFilter) {
return isDefined(value) && (value as number) > (fieldFilter.gt as number);
}
if ('gte' in fieldFilter) {
return isDefined(value) && (value as number) >= (fieldFilter.gte as number);
}
if ('lt' in fieldFilter) {
return isDefined(value) && (value as number) < (fieldFilter.lt as number);
}
if ('lte' in fieldFilter) {
return isDefined(value) && (value as number) <= (fieldFilter.lte as number);
}
throw new Error(
'Unsupported filter operator: ' + JSON.stringify(fieldFilter),
);
};