Files
twenty/packages/twenty-server/src/engine/metadata-modules/metadata-side-effect/services/metadata-side-effect-engine.service.ts
T
Paul Rastoin 43730d7748 Centralized side effects devxp basis (#22295)
# Introduction

This PR introduces a centralized, strictly-typed **metadata side-effect
engine** that unifies how system metadata side effects are derived and
applied across both metadata entry points — the **metadata GraphQL API**
and the **application sync / manifest** flow — and migrates the first
side effect end-to-end: **a unique scalar field owns its backing
single-field `UNIQUE` index** (full create / update / delete lifecycle).

## New conventions

- **Engine-owned companions**: metadata flagged `isSystemSideEffect:
true` is owned by the engine. Its deletion is never inferred from
absence in a manifest — it results from PG-level cascade or from a
delete side effect (a side effect always has a cause, its parent
metadata).
- **Reserved deterministic identifiers**: apps cannot declare metadata
reusing an engine-owned deterministic `universalIdentifier`. Doing so
fails validation with `RESERVED_SYSTEM_UNIVERSAL_IDENTIFIER` (until an
explicit override API exists).
- **Record-native operation matrix**: the operation matrix is keyed by
`universalIdentifier` (`AllFlatEntityOperationRecordByMetadataName`)
instead of arrays, making parent resolution and deduplication O(1).
Array-based API callers are transpiled to records at the
validate-build-and-run boundary.
- Twenty-sdk user-facing experience with system fields will only be
related to overrides.

# What this PR does

## 1. Side-effect engine (foundation)

- `MetadataSideEffectEngineService.expandWithSideEffects(...)` takes the
intention-carrying record matrix and returns it expanded with derived
side effects, or a structured failure.
- Handlers are registered via a typed **decorator + registry** pattern
(`MetadataSideEffectHandler({ operation, metadataName, name, description
})`), with runtime duplicate-name detection. Multiple handlers per
(operation, metadataName) are supported.
- Handler contract mirrors the validator pattern:
- receives the trigger flat entity, the live record matrix, and
**strictly-typed related flat entity maps**
(`MetadataFlatEntityAndRelatedFlatEntityMapsForSideEffect<P>`, derived
from declared companion metadata names — no loose
`Partial<AllFlatEntityMaps>` context)
- returns `MetadataSideEffectResult`: `success` (operations record) |
`noop` | `fail` (structured failure)
- **Non-recursion is structural**: triggers are read from the original
caller input, never from the expanded matrix, so a side effect can never
trigger another side effect.
- **Deduplication + collision detection**: side effects are deduped by
`universalIdentifier` per operation; a caller-declared entity colliding
with an engine-owned deterministic identifier is recorded as a
collision.
- **Unified failure channel**: handler failures and reserved-identifier
collisions are merged into the same `OrchestratorFailureReport` contract
as builder validation errors, and the run short-circuits (fail-closed,
nothing is applied).

## 2. First migrated side effect — unique field → backing unique index

Three handlers own the complete lifecycle of the deterministic
single-field `UNIQUE` index backing a unique scalar field:

- **create**: unique scalar field → generate the deterministic backing
index (`fieldUniqueBackingIndexOnCreate`)
- **update**: `isUnique` flips and renames of still-unique fields (the
index name — and therefore its deterministic identifier — derives from
the field name, so a rename drops the stale index and recreates the
deterministic one) (`fieldUniqueBackingIndexOnUpdate`)
- **delete**: cascade-delete the backing index
(`fieldUniqueBackingIndexOnDelete`)

Supporting rules:
- The primary key `id` field never spawns a backing index (uniqueness
comes from the PK constraint) — explicit `isPrimaryKeyFlatFieldMetadata`
guard.
- Parent object resolution is **optimistic-first**: an object created or
updated in the same batch wins over the workspace cache (so e.g.
renaming an object while flipping a field to unique builds the index
from the post-rename object), resolved in O(1) via the record matrix.
- A missing parent object is reported as a structured side-effect
failure, never silently skipped.

## 3. Path convergence — manifest and API share one flow

- The manifest sync now derives a from→to **record matrix** from the
cache and feeds `validateBuildAndRunWorkspaceMigrationFromRecord`, the
same flow the API uses — both paths converge on the engine.
- Manifest-side unique-index generation and API transpiler
system-unique-index handling were removed (declared/composite/relation
indexes stay untouched).
- New `WorkspaceMigrationFlatEntityMapsService` mutualizes
flat-entity-maps computation between the side-effect engine and the
builder: cache keys are derived from the caller metadata names (+
validation- and side-effect-related closures) instead of hardcoded
loads.
- App-scoping and pruning are folded into one shared primitive
(`getSubAllFlatEntityMapsByApplicationIdsOrThrow`): slicing dependency
maps to the involved applications always prunes dangling one-to-many
aggregators — callers can no longer forget it.
- **Behavior change**: an app extending another app's view with a view
field now syncs successfully (cross-app view-field extension), covered
by a dedicated integration test.

## 4. Backfill upgrade command (2.19)

`upgrade:2-19:backfill-system-unique-index-universal-identifier`
rewrites legacy system unique-index `universalIdentifier`s to their
deterministic value so the engine can own pre-existing indexes. The
backfill is **driven from `isUnique: true` fields** (mirroring the
engine ownership predicate — excludes PK / morph / relation fields) and
resolves each field's backing index in O(1).

# Bugs fixed along the way

- `database:reset` seeding failed with
`INDEX_FIELD_INVALID_DEFAULT_VALUE`: the engine derived a backing
`UNIQUE` index for the default `id` primary key. Fixed with the explicit
primary-key guard.
- `isUnique` updates on system-flagged standard fields (e.g.
auto-created `name`) did not trigger the backing-index side effect.
- Manifest sync crashed with "Could not find flat entity with universal
identifier ..." when app-scoped slices left dangling aggregator
references — fixed by centralizing pruning in the shared slice primitive
2026-07-03 18:13:20 +02:00

330 lines
12 KiB
TypeScript

import { Injectable } from '@nestjs/common';
import { type AllMetadataName } from 'twenty-shared/metadata';
import { isDefined } from 'twenty-shared/utils';
import { type AllFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/all-flat-entity-maps.type';
import { type AllFlatEntityOperationRecordByMetadataName } from 'src/engine/metadata-modules/flat-entity/types/all-flat-entity-operation-record-by-metadata-name.type';
import { type MetadataFlatEntityAndRelatedFlatEntityMapsForSideEffect } from 'src/engine/metadata-modules/flat-entity/types/metadata-flat-entity-and-related-flat-entity-maps-for-side-effect.type';
import { type MetadataUniversalFlatEntity } from 'src/engine/metadata-modules/flat-entity/types/metadata-universal-flat-entity.type';
import { getMetadataManyToOneRelatedNames } from 'src/engine/metadata-modules/flat-entity/utils/get-metadata-many-to-one-related-names.util';
import { getMetadataSideEffectCompanionNames } from 'src/engine/metadata-modules/flat-entity/utils/get-metadata-side-effect-companion-names.util';
import { isSystemSideEffectFlatEntity } from 'src/engine/metadata-modules/flat-entity/utils/is-system-side-effect-flat-entity.util';
import { MetadataSideEffectHandlerRegistryService } from 'src/engine/metadata-modules/metadata-side-effect/registry/metadata-side-effect-handler-registry.service';
import { type MetadataSideEffectContext } from 'src/engine/metadata-modules/metadata-side-effect/types/metadata-side-effect-context.type';
import { type MetadataSideEffectExpansionResult } from 'src/engine/metadata-modules/metadata-side-effect/types/metadata-side-effect-expansion-result.type';
import {
METADATA_SIDE_EFFECT_OPERATIONS,
type MetadataSideEffectOperation,
} from 'src/engine/metadata-modules/metadata-side-effect/types/metadata-side-effect-operation.type';
import { type MetadataSideEffectFailure } from 'src/engine/metadata-modules/metadata-side-effect/types/metadata-side-effect-result.type';
import { type SystemSideEffectUniversalIdentifierCollision } from 'src/engine/metadata-modules/metadata-side-effect/types/system-side-effect-universal-identifier-collision.type';
import { mapSystemSideEffectCollisionToFailure } from 'src/engine/metadata-modules/metadata-side-effect/utils/map-system-side-effect-collision-to-failure.util';
import { EMPTY_ORCHESTRATOR_FAILURE_REPORT } from 'src/engine/workspace-manager/workspace-migration/constant/empty-orchestrator-failure-report.constant';
import { pushToOrchestratorFailureReport } from 'src/engine/workspace-manager/workspace-migration/utils/merge-orchestrator-failure-reports.util';
type GenericUniversalFlatEntity = { universalIdentifier: string };
type GenericFlatEntityOperationRecord = {
flatEntityToCreate: Record<string, GenericUniversalFlatEntity>;
flatEntityToUpdate: Record<string, GenericUniversalFlatEntity>;
flatEntityToDelete: Record<string, GenericUniversalFlatEntity>;
};
type GenericAllFlatEntityOperationRecordByMetadataName = Record<
string,
GenericFlatEntityOperationRecord | undefined
>;
type GenericPartialFlatEntityOperationRecord = {
flatEntityToCreate?: Record<string, GenericUniversalFlatEntity>;
flatEntityToUpdate?: Record<string, GenericUniversalFlatEntity>;
flatEntityToDelete?: Record<string, GenericUniversalFlatEntity>;
};
type GenericMetadataSideEffectOperationsByMetadataName = Record<
string,
GenericPartialFlatEntityOperationRecord | undefined
>;
const OPERATION_TO_FLAT_ENTITY_RECORD_KEY = {
create: 'flatEntityToCreate',
update: 'flatEntityToUpdate',
delete: 'flatEntityToDelete',
} as const satisfies Record<
MetadataSideEffectOperation,
keyof GenericFlatEntityOperationRecord
>;
@Injectable()
export class MetadataSideEffectEngineService {
constructor(
private readonly metadataSideEffectHandlerRegistryService: MetadataSideEffectHandlerRegistryService,
) {}
getSideEffectRelatedMetadataNames(
triggerMetadataNames: AllMetadataName[],
): AllMetadataName[] {
const relatedMetadataNames = new Set<AllMetadataName>();
for (const {
metadataName,
} of this.metadataSideEffectHandlerRegistryService.getRegisteredHandlerKeys()) {
if (!triggerMetadataNames.includes(metadataName)) {
continue;
}
for (const relatedMetadataName of [
metadataName,
...getMetadataManyToOneRelatedNames(metadataName),
...getMetadataSideEffectCompanionNames(metadataName),
]) {
relatedMetadataNames.add(relatedMetadataName);
}
}
return [...relatedMetadataNames];
}
expandWithSideEffects({
allFlatEntityOperationRecordByMetadataName,
sideEffectRelatedFlatEntityMaps,
context,
}: {
allFlatEntityOperationRecordByMetadataName: AllFlatEntityOperationRecordByMetadataName;
sideEffectRelatedFlatEntityMaps: Partial<AllFlatEntityMaps>;
context: MetadataSideEffectContext;
}): MetadataSideEffectExpansionResult {
const expandedMatrix = this.cloneMatrix(
allFlatEntityOperationRecordByMetadataName,
);
const systemSideEffectUniversalIdentifierCollisions: SystemSideEffectUniversalIdentifierCollision[] =
[];
const sideEffectFailures: MetadataSideEffectFailure[] = [];
const triggerMatrix =
allFlatEntityOperationRecordByMetadataName as unknown as GenericAllFlatEntityOperationRecordByMetadataName;
for (const {
operation,
metadataName,
} of this.metadataSideEffectHandlerRegistryService.getRegisteredHandlerKeys()) {
const handlers =
this.metadataSideEffectHandlerRegistryService.getHandlers(
operation,
metadataName,
);
if (handlers.length === 0) {
continue;
}
const triggerFlatEntities = Object.values(
triggerMatrix[metadataName]?.[
OPERATION_TO_FLAT_ENTITY_RECORD_KEY[operation]
] ?? {},
);
for (const triggerFlatEntity of triggerFlatEntities) {
for (const handler of handlers) {
const sideEffectResult = handler.buildSideEffects({
flatEntity:
triggerFlatEntity as unknown as MetadataUniversalFlatEntity<AllMetadataName>,
allFlatEntityOperationRecordByMetadataName:
expandedMatrix as unknown as AllFlatEntityOperationRecordByMetadataName,
relatedFlatEntityMaps:
sideEffectRelatedFlatEntityMaps as unknown as MetadataFlatEntityAndRelatedFlatEntityMapsForSideEffect<AllMetadataName>,
context,
});
if (sideEffectResult.status === 'fail') {
sideEffectFailures.push(sideEffectResult);
continue;
}
if (sideEffectResult.status === 'noop') {
continue;
}
this.mergeSideEffectsIntoMatrix({
expandedMatrix,
sideEffectOperations:
sideEffectResult.operations as unknown as GenericMetadataSideEffectOperationsByMetadataName,
systemSideEffectUniversalIdentifierCollisions,
});
}
}
}
const allSideEffectFailures: MetadataSideEffectFailure[] = [
...sideEffectFailures,
...systemSideEffectUniversalIdentifierCollisions.map(
mapSystemSideEffectCollisionToFailure,
),
];
if (allSideEffectFailures.length > 0) {
const report = EMPTY_ORCHESTRATOR_FAILURE_REPORT();
for (const sideEffectFailure of allSideEffectFailures) {
pushToOrchestratorFailureReport({
report,
metadataName: sideEffectFailure.metadataName,
items: [sideEffectFailure],
});
}
return {
status: 'fail',
report,
};
}
return {
status: 'success',
allFlatEntityOperationRecordByMetadataName:
expandedMatrix as unknown as AllFlatEntityOperationRecordByMetadataName,
};
}
private mergeSideEffectsIntoMatrix({
expandedMatrix,
sideEffectOperations,
systemSideEffectUniversalIdentifierCollisions,
}: {
expandedMatrix: GenericAllFlatEntityOperationRecordByMetadataName;
sideEffectOperations: GenericMetadataSideEffectOperationsByMetadataName;
systemSideEffectUniversalIdentifierCollisions: SystemSideEffectUniversalIdentifierCollision[];
}): void {
for (const metadataName of Object.keys(sideEffectOperations)) {
const operationBuckets = sideEffectOperations[metadataName];
if (!isDefined(operationBuckets)) {
continue;
}
for (const operation of METADATA_SIDE_EFFECT_OPERATIONS) {
const sideEffectFlatEntities = Object.values(
operationBuckets[OPERATION_TO_FLAT_ENTITY_RECORD_KEY[operation]] ??
{},
);
for (const sideEffectFlatEntity of sideEffectFlatEntities) {
this.addToOperationIfAbsent({
expandedMatrix,
operation,
metadataName,
flatEntity: sideEffectFlatEntity,
systemSideEffectUniversalIdentifierCollisions,
});
}
}
}
}
private cloneMatrix(
allFlatEntityOperationRecordByMetadataName: AllFlatEntityOperationRecordByMetadataName,
): GenericAllFlatEntityOperationRecordByMetadataName {
const genericMatrix =
allFlatEntityOperationRecordByMetadataName as unknown as GenericAllFlatEntityOperationRecordByMetadataName;
const clonedMatrix: GenericAllFlatEntityOperationRecordByMetadataName = {};
for (const metadataName of Object.keys(genericMatrix)) {
const operations = genericMatrix[metadataName];
if (!isDefined(operations)) {
continue;
}
clonedMatrix[metadataName] = {
flatEntityToCreate: { ...operations.flatEntityToCreate },
flatEntityToUpdate: { ...operations.flatEntityToUpdate },
flatEntityToDelete: { ...operations.flatEntityToDelete },
};
}
return clonedMatrix;
}
private addToOperationIfAbsent({
expandedMatrix,
operation,
metadataName,
flatEntity,
systemSideEffectUniversalIdentifierCollisions,
}: {
expandedMatrix: GenericAllFlatEntityOperationRecordByMetadataName;
operation: MetadataSideEffectOperation;
metadataName: string;
flatEntity: GenericUniversalFlatEntity;
systemSideEffectUniversalIdentifierCollisions: SystemSideEffectUniversalIdentifierCollision[];
}): void {
const operations = (expandedMatrix[metadataName] ??= {
flatEntityToCreate: {},
flatEntityToUpdate: {},
flatEntityToDelete: {},
});
const flatEntityRecordKey = OPERATION_TO_FLAT_ENTITY_RECORD_KEY[operation];
const flatEntityRecord = operations[flatEntityRecordKey];
const existingFlatEntity = flatEntityRecord[flatEntity.universalIdentifier];
if (isDefined(existingFlatEntity)) {
this.recordUniversalIdentifierCollisionIfNeeded({
existingFlatEntity,
operation,
metadataName,
flatEntity,
systemSideEffectUniversalIdentifierCollisions,
});
return;
}
flatEntityRecord[flatEntity.universalIdentifier] = flatEntity;
}
private recordUniversalIdentifierCollisionIfNeeded({
existingFlatEntity,
operation,
metadataName,
flatEntity,
systemSideEffectUniversalIdentifierCollisions,
}: {
existingFlatEntity: GenericUniversalFlatEntity;
operation: MetadataSideEffectOperation;
metadataName: string;
flatEntity: GenericUniversalFlatEntity;
systemSideEffectUniversalIdentifierCollisions: SystemSideEffectUniversalIdentifierCollision[];
}): void {
const isIncomingSystemSideEffect = isSystemSideEffectFlatEntity(
flatEntity as unknown as MetadataUniversalFlatEntity<AllMetadataName>,
);
if (!isIncomingSystemSideEffect) {
return;
}
if (
isSystemSideEffectFlatEntity(
existingFlatEntity as unknown as MetadataUniversalFlatEntity<AllMetadataName>,
)
) {
return;
}
systemSideEffectUniversalIdentifierCollisions.push({
metadataName: metadataName as AllMetadataName,
operation,
universalIdentifier: flatEntity.universalIdentifier,
name: this.extractFlatEntityName(flatEntity),
});
}
private extractFlatEntityName(
flatEntity: GenericUniversalFlatEntity,
): string | undefined {
const { name } = flatEntity as { name?: unknown };
return typeof name === 'string' ? name : undefined;
}
}